From a8425fb6c25f17a6da35c7a4998b4c1891aa9790 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 16:57:55 +0100 Subject: [PATCH 01/25] Refactor test structure: move test files to `app/tests/`, update imports, and revise documentation for consistency. --- README.md | 2 +- app/docs/dev.md | 2 +- app/evaluation_tests.py | 2 +- app/{ => tests}/evaluation_test.py | 4 ++-- app/{preview_test.py => tests/symbolic_preview_test.py} | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename app/{ => tests}/evaluation_test.py (99%) rename app/{preview_test.py => tests/symbolic_preview_test.py} (99%) diff --git a/README.md b/README.md index e8b0029..b80093e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This version is specifically for python, however the ultimate goal is to make si 5. You are now ready to start developing your function: - Edit the `app/evaluation.py` file, which ultimately gets called when the function is given the `eval` command - - Add pytest tests to `app/evaluation_test.py`, `app/preview_test.py` or `app/tests/`. They get run: + - Add pytest tests to `app/tests/`. They get run: - On every pull request and before the image is built and deployed (`pytest` from the repo root) - Whenever the `healthcheck` command is supplied to the deployed function (via `app/evaluation_tests.py`) - Edit `app/docs/user.md` and `app/docs/dev.md` to reflect your changes. These files are baked into the function's image, and is made available using the `docs` command. This feature is used to display this function's documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website once it's been hooked up! diff --git a/app/docs/dev.md b/app/docs/dev.md index e3782fd..13818c2 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -234,6 +234,6 @@ grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin ``` # Tests -There are two main groups of tests, evaluation tests (`evaluation_test.py` and `tests/*_evaluation_test.py`) and preview tests (`preview_test.py` and `tests/*_preview_test.py`). Run them all with `pytest` from the repository root. +The tests are in `tests/` and fall into two main groups: evaluation tests (`evaluation_test.py` and `*_evaluation_test.py`) and preview tests (`*_preview_test.py`). Run them all with `pytest` from the repository root. The base image ships its own tests in the same `tests/` directory (e.g. `commands_test.py`, `parse_test.py`), so don't reuse those file names. The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. \ No newline at end of file diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py index a29f356..2ff26a5 100644 --- a/app/evaluation_tests.py +++ b/app/evaluation_tests.py @@ -3,7 +3,7 @@ The base image's healthcheck loads `TestEvaluationFunction` from this module with `unittest`, so this wraps a run of the pytest suite in this directory - (`*_test.py` and `tests/`). Tests marked `@pytest.mark.slow` are left out so + (`tests/`). Tests marked `@pytest.mark.slow` are left out so the healthcheck stays within the request timeout; CI runs them all. pytest doesn't collect `*_tests.py`, so this module isn't picked up by that run. """ diff --git a/app/evaluation_test.py b/app/tests/evaluation_test.py similarity index 99% rename from app/evaluation_test.py rename to app/tests/evaluation_test.py index 1239a68..c44f505 100755 --- a/app/evaluation_test.py +++ b/app/tests/evaluation_test.py @@ -1,7 +1,7 @@ import pytest -from .preview import preview_function -from .evaluation import evaluation_function +from ..preview import preview_function +from ..evaluation import evaluation_function class TestEvaluationFunction(): diff --git a/app/preview_test.py b/app/tests/symbolic_preview_test.py similarity index 99% rename from app/preview_test.py rename to app/tests/symbolic_preview_test.py index ba762d5..ef47f57 100644 --- a/app/preview_test.py +++ b/app/tests/symbolic_preview_test.py @@ -1,6 +1,6 @@ import pytest -from .preview import preview_function +from ..preview import preview_function class TestPreviewFunction(): From ba973b96cf913aa07d817d549a11718a8a457992 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 17:02:42 +0100 Subject: [PATCH 02/25] Refactor preview structure: move context-specific preview functions, types, and imports to `app/preview/`. --- app/context/physical_quantity.py | 2 +- app/context/symbolic.py | 2 +- app/docs/dev.md | 4 +-- app/{preview.py => preview/__init__.py} | 6 ++--- .../physical_quantity.py} | 2 +- .../symbolic.py} | 27 +++---------------- app/tests/physical_quantity_preview_test.py | 2 +- 7 files changed, 12 insertions(+), 33 deletions(-) rename app/{preview.py => preview/__init__.py} (84%) rename app/{context/physical_quantity_preview.py => preview/physical_quantity.py} (98%) rename app/{symbolic_preview.py => preview/symbolic.py} (85%) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index b178fc9..4d1afca 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -12,7 +12,7 @@ ) from lambdafeedback.units import QuantityParams, preprocess_quantity from lambdafeedback.units import parse_quantity as parse_physical_quantity -from .physical_quantity_preview import preview_function +from ..preview.physical_quantity import preview_function from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ..feedback.physical_quantity import reverted_unit_feedback from .symbolic import default_parameters as symbolic_default_parameters diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 04beb90..63aafde 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -10,7 +10,7 @@ preprocess_expression, ) -from ..symbolic_preview import preview_function +from ..preview.symbolic import preview_function from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ..feedback.syntactical_forms import PATTERNS as syntactical_forms diff --git a/app/docs/dev.md b/app/docs/dev.md index 13818c2..7afb4f4 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -206,9 +206,9 @@ The function returns result dictionary with the following fields: # Preview function -When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions are `symbolic_preview.py` (symbolic expressions) and `context/physical_quantity_preview.py` (physical quantities), built on the parsing from the `lambdafeedback-expression-parsing` and `lambdafeedback-units` packages, see [Packages](#packages). +When the evaluation function preview is called the code in `preview/__init__.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions are `preview/symbolic.py` (symbolic expressions) and `preview/physical_quantity.py` (physical quantities), with the shared `Preview` and `Result` types in `preview/common.py`, built on the parsing from the `lambdafeedback-expression-parsing` and `lambdafeedback-units` packages, see [Packages](#packages). -**Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case the `preview.py` and `evaluation.py` could also share the same code for determining the right context to use. +**Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case `preview/__init__.py` and `evaluation.py` could also share the same code for determining the right context to use. When `is_latex` is enabled, `e` and `E` are read as plain symbols by the LaTeX parser (latex2sympy2_extended); Euler's number comes from `elementary_functions` when the result is parsed, as for text input. See the PR for the earlier latex2sympy2 workaround: https://github.com/lambda-feedback/compareExpressions/pull/235 diff --git a/app/preview.py b/app/preview/__init__.py similarity index 84% rename from app/preview.py rename to app/preview/__init__.py index 0dca3cd..7e9df78 100644 --- a/app/preview.py +++ b/app/preview/__init__.py @@ -1,9 +1,9 @@ from collections.abc import Mapping from typing import Any -from .symbolic_preview import Result -from .symbolic_preview import preview_function as symbolic_preview -from .context.physical_quantity_preview import preview_function as physical_quantity_preview +from .common import Result +from .symbolic import preview_function as symbolic_preview +from .physical_quantity import preview_function as physical_quantity_preview def preview_function(response: str, params: Mapping[str, Any]) -> Result: diff --git a/app/context/physical_quantity_preview.py b/app/preview/physical_quantity.py similarity index 98% rename from app/context/physical_quantity_preview.py rename to app/preview/physical_quantity.py index 427510e..7029cd6 100644 --- a/app/context/physical_quantity_preview.py +++ b/app/preview/physical_quantity.py @@ -22,7 +22,7 @@ preprocess_quantity, ) -from ..symbolic_preview import Preview, Result +from .common import Preview, Result def fix_exponents(response: str) -> str: diff --git a/app/symbolic_preview.py b/app/preview/symbolic.py similarity index 85% rename from app/symbolic_preview.py rename to app/preview/symbolic.py index 350a4ca..741c1c6 100644 --- a/app/symbolic_preview.py +++ b/app/preview/symbolic.py @@ -8,7 +8,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, NotRequired, TypedDict +from typing import Any from sympy import Basic @@ -27,29 +27,8 @@ sympy_to_latex, ) -from .feedback.tags import FeedbackTag, FeedbackTagName - - -class ExpressionSyntaxError(ExpressionParsingError): - """A response could not be parsed; ``feedback`` is the tag to show the learner.""" - - def __init__(self, feedback: FeedbackTag) -> None: - super().__init__(f"{feedback.tag}: {dict(feedback.inputs)}") - self.feedback = feedback - - -class Preview(TypedDict): - """Structurally compatible with ``lf_toolkit.preview.Preview`` (whose ``feedback`` is required).""" - - latex: str - sympy: str - feedback: NotRequired[str] - - -class Result(TypedDict): - """Structurally compatible with ``lf_toolkit.preview.Result``.""" - - preview: Preview +from ..feedback.tags import FeedbackTag, FeedbackTagName +from .common import ExpressionSyntaxError, Preview, Result def _as_params(params: ExpressionParams | Mapping[str, Any]) -> ExpressionParams: diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index ffb32d2..a23d8c3 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -2,7 +2,7 @@ import pytest -from ..context.physical_quantity_preview import fix_exponents, preview_function +from ..preview.physical_quantity import fix_exponents, preview_function from .physical_quantity_test_cases import slr_natural_si_syntax_test_cases, slr_strict_si_syntax_test_cases From 9218edf2cbb62b76dc84de7f3c2778e1d3ff94da Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 14 Oct 2026 07:49:26 +0100 Subject: [PATCH 03/25] Refactor symbolic and numerical contexts: extract set equivalence and numerical comparison logic into dedicated modules --- app/context/numerical.py | 45 +++++++++++ app/context/set_equivalence.py | 102 +++++++++++++++++++++++++ app/context/symbolic.py | 135 +++------------------------------ app/preview/common.py | 31 ++++++++ 4 files changed, 188 insertions(+), 125 deletions(-) create mode 100644 app/context/numerical.py create mode 100644 app/context/set_equivalence.py create mode 100644 app/preview/common.py diff --git a/app/context/numerical.py b/app/context/numerical.py new file mode 100644 index 0000000..f179d1b --- /dev/null +++ b/app/context/numerical.py @@ -0,0 +1,45 @@ +from sympy import pi, N + + +def compare_numerically(lhs_expr, rhs_expr, parameters_dict): + """ + Compares `lhs_expr` and `rhs_expr` numerically using the `atol` and `rtol` parameters. + + Returns False if numerical comparison is not enabled (`numerical` is not set and neither + `atol` nor `rtol` is positive), otherwise whether the difference is within both tolerances. + """ + if not (parameters_dict.get("numerical", False) + or float(parameters_dict.get("rtol", 0)) > 0 + or float(parameters_dict.get("atol", 0)) > 0): + return False + + # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. + # The two lines below this comments fixes the issue but a more robust solution should be found for cases where there + # are other reserved symbols. + def replace_pi(expr): + pi_symbol = pi + for s in expr.free_symbols: + if str(s) == 'pi': + pi_symbol = s + return expr.subs(pi_symbol, float(pi)) + # NOTE: This code assumes that the left hand side is the response and the right hand side is the answer + # Separates LHS and RHS, parses and evaluates them + res = N(replace_pi(lhs_expr)) + ans = N(replace_pi(rhs_expr)) + if float(parameters_dict.get("atol", 0)) > 0: + try: + absolute_error = abs(float(ans-res)) + error_below_atol = bool(absolute_error < float(parameters_dict["atol"])) + except TypeError: + error_below_atol = None + else: + error_below_atol = True + if float(parameters_dict.get("rtol", 0)) > 0: + try: + relative_error = abs(float((ans-res)/ans)) + error_below_rtol = bool(relative_error < float(parameters_dict["rtol"])) + except TypeError: + error_below_rtol = None + else: + error_below_rtol = True + return error_below_atol is True and error_below_rtol is True diff --git a/app/context/set_equivalence.py b/app/context/set_equivalence.py new file mode 100644 index 0000000..a06aa41 --- /dev/null +++ b/app/context/set_equivalence.py @@ -0,0 +1,102 @@ +from sympy import Equality + +from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators + + +def as_sets(response, answer): + """ + Returns whether `response` and `answer` should be compared as sets, together with + `response` and `answer` wrapped in a set if only the other one is a set. + """ + if isinstance(response, set) and isinstance(answer, set): + return True, response, answer + elif isinstance(response, set) and not isinstance(answer, set): + return True, response, set([answer]) + elif not isinstance(response, set) and isinstance(answer, set): + return True, set([response]), answer + return False, response, answer + + +def add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs, + response_list, answer_list, check_equality): + """ + Adds the nodes that check if each element of `response_list` is equal to an element of + `answer_list` (and vice versa, depending on `multiple_answers_criteria`) to `graph`. + `check_equality` compares a single response with a single answer. + """ + def set_equivalence(unused_input): + matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} + for i, response in enumerate(response_list): + result = None + for j, answer in enumerate(answer_list): + current_pair = [("response", response), ("answer", answer)] + if isinstance(response, Equality) and not isinstance(answer, Equality): + result = False + elif not isinstance(response, Equality) and isinstance(answer, Equality): + result = False + else: + result = check_equality(criterion, parameters_dict, local_substitutions=current_pair) + if result is True: + matches["responses"][i] = True + matches["answers"][j] = True + if parameters_dict["multiple_answers_criteria"] == "all": + is_correct = all(matches["responses"]) and all(matches["answers"]) + if is_correct is False: + return { + label+"_MULTIPLE_ANSWER_FAIL_ALL": None + } + elif parameters_dict["multiple_answers_criteria"] == "all_responses": + is_correct = all(matches["responses"]) + if is_correct is False: + return { + label+"_MULTIPLE_ANSWER_FAIL_RESPONSE": None + } + elif parameters_dict["multiple_answers_criteria"] == "all_answers": + is_correct = all(matches["answers"]) + if is_correct is False: + return { + label+"_MULTIPLE_ANSWER_FAIL_ANSWER": None + } + else: + raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters_dict['multiple_answers_criteria']}") + return { + label+"_TRUE": None + } + + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+"="+str(rhs)+".", + evaluate=set_equivalence + ) + graph.attach_new_criterion( + label, + label+"_TRUE", + summary=str(lhs)+"="+str(rhs), + details=str(lhs)+" is equal to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") + ) + graph.attach_new_criterion( + label, + label+"_MULTIPLE_ANSWER_FAIL_ALL", + summary=str(lhs)+" is not equal to "+str(rhs), + details="At least one answer or response was incorrect.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") + ) + graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label) + graph.attach_new_criterion( + label, + label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", + summary="Unexpected element in response.", + details="At least one response was incorrect.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE") + ) + graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label) + graph.attach_new_criterion( + label, + label+"_MULTIPLE_ANSWER_FAIL_ANSWER", + summary="Missing element in response", + details="At least one answer is missing in the response.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ANSWER") + ) + graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ANSWER", END.label) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 63aafde..c85e36e 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,4 +1,4 @@ -from sympy import Add, Pow, Mul, Equality, pi, im, I, N +from sympy import Add, Pow, Mul, Equality, im, I from sympy import re as real_part from lambdafeedback.expression_parsing import ( @@ -11,6 +11,8 @@ ) from ..preview.symbolic import preview_function +from .numerical import compare_numerically +from .set_equivalence import add_set_equivalence_nodes, as_sets from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ..feedback.syntactical_forms import PATTERNS as syntactical_forms @@ -149,44 +151,8 @@ def check_equality(criterion, parameters_dict, local_substitutions=[]): if result is False: result = do_comparison(criterion.content, lhs_expr.simplify()-rhs_expr.simplify()) - # TODO: Make numerical comparison its own context if result is False: - error_below_rtol = None - error_below_atol = None - if parameters_dict.get("numerical", False) or float(parameters_dict.get("rtol", 0)) > 0 or float(parameters_dict.get("atol", 0)) > 0: - # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. - # The two lines below this comments fixes the issue but a more robust solution should be found for cases where there - # are other reserved symbols. - def replace_pi(expr): - pi_symbol = pi - for s in expr.free_symbols: - if str(s) == 'pi': - pi_symbol = s - return expr.subs(pi_symbol, float(pi)) - # NOTE: This code assumes that the left hand side is the response and the right hand side is the answer - # Separates LHS and RHS, parses and evaluates them - res = N(replace_pi(lhs_expr)) - ans = N(replace_pi(rhs_expr)) - if float(parameters_dict.get("atol", 0)) > 0: - try: - absolute_error = abs(float(ans-res)) - error_below_atol = bool(absolute_error < float(parameters_dict["atol"])) - except TypeError: - error_below_atol = None - else: - error_below_atol = True - if float(parameters_dict.get("rtol", 0)) > 0: - try: - relative_error = abs(float((ans-res)/ans)) - error_below_rtol = bool(relative_error < float(parameters_dict["rtol"])) - except TypeError: - error_below_rtol = None - else: - error_below_rtol = True - if error_below_atol is None or error_below_rtol is None: - result = False - elif error_below_atol is True and error_below_rtol is True: - result = True + result = compare_numerically(lhs_expr, rhs_expr, parameters_dict) return result @@ -292,45 +258,6 @@ def mathematical_equivalence(unused_input): label+"_UNKNOWN": None } - def set_equivalence(unused_input): - matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} - for i, response in enumerate(response_list): - result = None - for j, answer in enumerate(answer_list): - current_pair = [("response", response), ("answer", answer)] - if isinstance(response, Equality) and not isinstance(answer, Equality): - result = False - elif not isinstance(response, Equality) and isinstance(answer, Equality): - result = False - else: - result = check_equality(criterion, parameters_dict, local_substitutions=current_pair) - if result is True: - matches["responses"][i] = True - matches["answers"][j] = True - if parameters_dict["multiple_answers_criteria"] == "all": - is_correct = all(matches["responses"]) and all(matches["answers"]) - if is_correct is False: - return { - label+"_MULTIPLE_ANSWER_FAIL_ALL": None - } - elif parameters_dict["multiple_answers_criteria"] == "all_responses": - is_correct = all(matches["responses"]) - if is_correct is False: - return { - label+"_MULTIPLE_ANSWER_FAIL_RESPONSE": None - } - elif parameters_dict["multiple_answers_criteria"] == "all_answers": - is_correct = all(matches["answers"]) - if is_correct is False: - return { - label+"_MULTIPLE_ANSWER_FAIL_ANSWER": None - } - else: - raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters_dict['multiple_answers_critera']}") - return { - label+"_TRUE": None - } - def equality_equivalence(unused_input): result = False res = parameters_dict["reserved_expressions"]["response"] @@ -385,61 +312,19 @@ def same_symbols(unused_input): label+"_SAME_SYMBOLS"+"_FALSE": None } - use_set_equivalence = False - response_list = parameters_dict["reserved_expressions"]["response"] - answer_list = parameters_dict["reserved_expressions"]["answer"] - if isinstance(response_list, set) and isinstance(answer_list, set): - use_set_equivalence = True - elif isinstance(response_list, set) and not isinstance(answer_list, set): - use_set_equivalence = True - answer_list = set([answer_list]) - elif not isinstance(response_list, set) and isinstance(answer_list, set): - use_set_equivalence = True - response_list = set([response_list]) + use_set_equivalence, response_list, answer_list = as_sets( + parameters_dict["reserved_expressions"]["response"], + parameters_dict["reserved_expressions"]["answer"], + ) res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] use_equality_equivalence = isinstance(res, Equality) or isinstance(ans, Equality) - # TODO: Make checking set equivalence its own context that calls symbolic comparisons instead if use_set_equivalence is True: - graph.add_evaluation_node( - label, - summary=label, - details="Checks if "+str(lhs)+"="+str(rhs)+".", - evaluate=set_equivalence - ) - graph.attach_new_criterion( - label, - label+"_TRUE", - summary=str(lhs)+"="+str(rhs), - details=str(lhs)+" is equal to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") - ) - graph.attach_new_criterion( - label, - label+"_MULTIPLE_ANSWER_FAIL_ALL", - summary=str(lhs)+" is not equal to "+str(rhs), - details="At least one answer or response was incorrect.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") - ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label) - graph.attach_new_criterion( - label, - label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", - summary="Unexpected element in response.", - details="At least one response was incorrect.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE") - ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label) - graph.attach_new_criterion( - label, - label+"_MULTIPLE_ANSWER_FAIL_ANSWER", - summary="Missing element in response", - details="At least one answer is missing in the response.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ANSWER") + add_set_equivalence_nodes( + graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list, check_equality ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ANSWER", END.label) elif use_equality_equivalence is True: graph.add_evaluation_node( label, diff --git a/app/preview/common.py b/app/preview/common.py new file mode 100644 index 0000000..756d87d --- /dev/null +++ b/app/preview/common.py @@ -0,0 +1,31 @@ +"""Types shared by the symbolic and physical quantity previews.""" + +from __future__ import annotations + +from typing import NotRequired, TypedDict + +from lambdafeedback.expression_parsing import ExpressionParsingError + +from ..feedback.tags import FeedbackTag + + +class ExpressionSyntaxError(ExpressionParsingError): + """A response could not be parsed; ``feedback`` is the tag to show the learner.""" + + def __init__(self, feedback: FeedbackTag) -> None: + super().__init__(f"{feedback.tag}: {dict(feedback.inputs)}") + self.feedback = feedback + + +class Preview(TypedDict): + """Structurally compatible with ``lf_toolkit.preview.Preview`` (whose ``feedback`` is required).""" + + latex: str + sympy: str + feedback: NotRequired[str] + + +class Result(TypedDict): + """Structurally compatible with ``lf_toolkit.preview.Result``.""" + + preview: Preview From 1af3e00676bfe0deada8637082a2c6b5c3f96567 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 11:39:41 +0100 Subject: [PATCH 04/25] Refactor context structure: reorganize symbolic and physical_quantity contexts into packages, update imports, and revise documentation for clarity --- .../__init__.py} | 8 ++++---- app/context/{symbolic.py => symbolic/__init__.py} | 6 +++--- app/context/{ => symbolic}/numerical.py | 0 app/context/{ => symbolic}/set_equivalence.py | 2 +- app/docs/dev.md | 10 ++++++---- 5 files changed, 14 insertions(+), 12 deletions(-) rename app/context/{physical_quantity.py => physical_quantity/__init__.py} (98%) rename app/context/{symbolic.py => symbolic/__init__.py} (99%) rename app/context/{ => symbolic}/numerical.py (100%) rename app/context/{ => symbolic}/set_equivalence.py (97%) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity/__init__.py similarity index 98% rename from app/context/physical_quantity.py rename to app/context/physical_quantity/__init__.py index 4d1afca..fdcf9eb 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity/__init__.py @@ -12,10 +12,10 @@ ) from lambdafeedback.units import QuantityParams, preprocess_quantity from lambdafeedback.units import parse_quantity as parse_physical_quantity -from ..preview.physical_quantity import preview_function -from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from ..feedback.physical_quantity import reverted_unit_feedback -from .symbolic import default_parameters as symbolic_default_parameters +from ...preview.physical_quantity import preview_function +from ...feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators +from ...feedback.physical_quantity import reverted_unit_feedback +from ..symbolic import default_parameters as symbolic_default_parameters from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate from sympy import Symbol diff --git a/app/context/symbolic.py b/app/context/symbolic/__init__.py similarity index 99% rename from app/context/symbolic.py rename to app/context/symbolic/__init__.py index c85e36e..bf11a99 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic/__init__.py @@ -10,11 +10,11 @@ preprocess_expression, ) -from ..preview.symbolic import preview_function +from ...preview.symbolic import preview_function from .numerical import compare_numerically from .set_equivalence import add_set_equivalence_nodes, as_sets -from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ..feedback.syntactical_forms import PATTERNS as syntactical_forms +from ...feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ...feedback.syntactical_forms import PATTERNS as syntactical_forms from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser diff --git a/app/context/numerical.py b/app/context/symbolic/numerical.py similarity index 100% rename from app/context/numerical.py rename to app/context/symbolic/numerical.py diff --git a/app/context/set_equivalence.py b/app/context/symbolic/set_equivalence.py similarity index 97% rename from app/context/set_equivalence.py rename to app/context/symbolic/set_equivalence.py index a06aa41..3a264d1 100644 --- a/app/context/set_equivalence.py +++ b/app/context/symbolic/set_equivalence.py @@ -1,6 +1,6 @@ from sympy import Equality -from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ...feedback.symbolic import feedback_generators as symbolic_feedback_string_generators def as_sets(response, answer): diff --git a/app/docs/dev.md b/app/docs/dev.md index 7afb4f4..58fae8a 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -48,7 +48,7 @@ The overall flow of the evaluation procedure can be described as follows: ### Context The context is a data structure that contains at least the following seven pieces of information: -- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `context\symbolic.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.py`. +- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `context/symbolic/__init__.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context/physical_quantity/__init__.py`. - `expression_parse` function that parses expressions (i.e. the `response` and `answer` inputs) into the form used by the feedback generation procedure. - `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. - `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. @@ -61,8 +61,10 @@ The context can also contain other fields if necessary. **Remark:** The current implementation uses a dictionary rather than a dedicated class for ease of iteration during the initial development phase. There are currently two different contexts: -- `symbolic`: Handles comparisons of various symbolic expressions. Defined in `context\symbolic.py`. -- `physical_quantity`: Handles comparisons of expressions involving units. Defined in `context\physical_quantity.py`. +- `symbolic`: Handles comparisons of various symbolic expressions. Defined in the `context/symbolic/` package. +- `physical_quantity`: Handles comparisons of expressions involving units. Defined in the `context/physical_quantity/` package. + +Each context package exposes its `context` dictionary from its `__init__.py`. **Remark:** Handwritten expressions are sent as latex, which requires extra preprocessing before the right context can be determined in some cases. It should be considered whether a new context, perhaps called `handwritten`, should be created for this purpose. @@ -208,7 +210,7 @@ The function returns result dictionary with the following fields: When the evaluation function preview is called the code in `preview/__init__.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions are `preview/symbolic.py` (symbolic expressions) and `preview/physical_quantity.py` (physical quantities), with the shared `Preview` and `Result` types in `preview/common.py`, built on the parsing from the `lambdafeedback-expression-parsing` and `lambdafeedback-units` packages, see [Packages](#packages). -**Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case `preview/__init__.py` and `evaluation.py` could also share the same code for determining the right context to use. +**Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (e.g. by moving the preview implementation into the context packages `context/symbolic/` and `context/physical_quantity/`). In this case `preview/__init__.py` and `evaluation.py` could also share the same code for determining the right context to use. When `is_latex` is enabled, `e` and `E` are read as plain symbols by the LaTeX parser (latex2sympy2_extended); Euler's number comes from `elementary_functions` when the result is parsed, as for text input. See the PR for the earlier latex2sympy2 workaround: https://github.com/lambda-feedback/compareExpressions/pull/235 From bd50534f44e1d8e2bc41aaa88c41083ab8fb23dc Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 11:48:06 +0100 Subject: [PATCH 05/25] Refactor graph helpers: extract shared helper functions (`attach_terminal_criterion`, `disable_nodes`, `outcome_tag`) and integrate them across symbolic and physical_quantity contexts --- app/context/graph_helpers.py | 32 +++++ app/context/physical_quantity/__init__.py | 84 ++++++------- app/context/symbolic/__init__.py | 138 ++++++++-------------- app/context/symbolic/set_equivalence.py | 16 ++- 4 files changed, 129 insertions(+), 141 deletions(-) create mode 100644 app/context/graph_helpers.py diff --git a/app/context/graph_helpers.py b/app/context/graph_helpers.py new file mode 100644 index 0000000..8765751 --- /dev/null +++ b/app/context/graph_helpers.py @@ -0,0 +1,32 @@ +"""Helpers for building the `CriteriaGraph`s shared by the symbolic and physical quantity contexts.""" + +from lambdafeedback.criteria import CriteriaGraph + + +def outcome_tag(label, result, inputs=None): + """`{label+"_TRUE": inputs}` if `result` is True, `label+"_FALSE"` if it is False, otherwise `label+"_UNKNOWN"`.""" + if result is True: + return {label+"_TRUE": inputs} + elif result is False: + return {label+"_FALSE": inputs} + else: + return {label+"_UNKNOWN": inputs} + + +def attach_terminal_criterion(graph, evaluation_label, label, end, summary, details, feedback_string_generator=None): + """Attaches a new criterion `label` to `evaluation_label` and connects it to the `end` node.""" + graph.attach_new_criterion( + evaluation_label, + label, + summary=summary, + details=details, + feedback_string_generator=feedback_string_generator + ) + graph.attach(label, end.label) + + +def disable_nodes(graph, disabled_evaluation_nodes): + """Makes each evaluation in `disabled_evaluation_nodes` skip straight to the end of `graph`.""" + for evaluation in graph.evaluations.values(): + if evaluation.label in disabled_evaluation_nodes: + evaluation.replacement = CriteriaGraph.END_LABEL diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index fdcf9eb..1665e97 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -16,6 +16,7 @@ from ...feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ...feedback.physical_quantity import reverted_unit_feedback from ..symbolic import default_parameters as symbolic_default_parameters +from ..graph_helpers import attach_terminal_criterion, disable_nodes, outcome_tag from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate from sympy import Symbol @@ -146,10 +147,7 @@ def comparison_base_graph(criterion, parameters, comparison_operator="=", label= compare = comparison_function(comparison_operator) def compare_evaluate(unused_input): - if compare(lhs, rhs, stardard_forms) is True: - return {label+"_TRUE": None} - else: - return {label+"_FALSE": None} + return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True) graph.add_evaluation_node( label, @@ -157,22 +155,24 @@ def compare_evaluate(unused_input): details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", evaluate=compare_evaluate ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_TRUE", + END, summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", details=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE") ) - graph.attach(label+"_TRUE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_FALSE", + END, summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", details=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("FALSE") ) - graph.attach(label+"_FALSE", END.label) # TODO: Consider adding node for cases where comparison cannot be done / cannot be determined return graph @@ -276,32 +276,12 @@ def quantity_match(unused_inputs): substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] unit_match = is_equal(lhs, rhs, substitutions) - output_tags = None - if value_match is True and unit_match is True: - output_tags = { - label+"_TRUE": {"lhs": lhs_string, "rhs": rhs_string} - } - else: - output_tags = { - label+"_FALSE": {"lhs": lhs_string, "rhs": rhs_string} - } - - return output_tags + return outcome_tag(label, value_match is True and unit_match is True, {"lhs": lhs_string, "rhs": rhs_string}) def dimension_match(unused_inputs): substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] dimension_match, _ = is_proportional(lhs, rhs, substitutions) - - output_tags = None - if dimension_match is True: - output_tags = { - label+"_DIMENSION_MATCH"+"_TRUE": {"lhs": lhs_string, "rhs": rhs_string} - } - else: - output_tags = { - label+"_DIMENSION_MATCH"+"_FALSE": {"lhs": lhs_string, "rhs": rhs_string} - } - return output_tags + return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) def unit_comparison(unused_inputs): substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] @@ -348,38 +328,42 @@ def unit_comparison(unused_inputs): details=f"The quantities {inputs[0]} and {inputs[1]} does not match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MISMATCH") ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_MISSING_VALUE", + END, summary="The response is missing a value.", details="The response is missing a value.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE") ) - graph.attach(label+"_MISSING_VALUE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_UNEXPECTED_VALUE", + END, summary="The response is expected only have unit(s), no value.", details="The response is expected only have unit(s), no value.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE") ) - graph.attach(label+"_UNEXPECTED_VALUE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_MISSING_UNIT", + END, summary="The response is missing unit(s).", details="The response is missing unit(s).", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT") ) - graph.attach(label+"_MISSING_UNIT", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_UNEXPECTED_UNIT", + END, summary="The response is expected to be a value without unit(s).", details="The response is expected to be a value without unit(s).", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") ) - graph.attach(label+"_UNEXPECTED_UNIT", END.label) graph.attach_new_evaluation( label+"_FALSE", label+"_DIMENSION_MATCH", @@ -387,22 +371,24 @@ def unit_comparison(unused_inputs): details=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", evaluate=dimension_match ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_DIMENSION_MATCH", label+"_DIMENSION_MATCH"+"_TRUE", + END, summary=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", details=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MATCH") ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_DIMENSION_MATCH", label+"_DIMENSION_MATCH"+"_FALSE", + END, summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", details=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MISMATCH") ) - graph.attach(label+"_DIMENSION_MATCH"+"_TRUE", END.label) - graph.attach(label+"_DIMENSION_MATCH"+"_FALSE", END.label) graph.attach_new_evaluation( label+"_TRUE", label+"_UNIT_COMPARISON", @@ -424,22 +410,24 @@ def unit_comparison(unused_inputs): details=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_SIMILAR") ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", + END, summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", details=f"The units of {inputs[0]} are at least 1000 times greater than the units of {inputs[1]}.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE") ) - graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", + END, summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", details=f"The units of {inputs[0]} are at least 1000 times smaller than the units of {inputs[1]}.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_SMALL") ) - graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", END.label) return graph @@ -456,9 +444,7 @@ def feedback_procedure_generator(parameters_dict): } graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) graph = graph_template(criterion, parameters_dict) - for evaluation in graph.evaluations.values(): - if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END_LABEL + disable_nodes(graph, parameters_dict.get("disabled_evaluation_nodes", set())) graphs.update({label: graph}) return graphs diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index bf11a99..30a9248 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -15,6 +15,7 @@ from .set_equivalence import add_set_equivalence_nodes, as_sets from ...feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ...feedback.syntactical_forms import PATTERNS as syntactical_forms +from ..graph_helpers import attach_terminal_criterion, disable_nodes, outcome_tag from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser @@ -244,19 +245,7 @@ def criterion_equality_node(criterion, parameters_dict, label=None): label = criterion.content_string() def mathematical_equivalence(unused_input): - result = check_equality(criterion, parameters_dict) - if result is True: - return { - label+"_TRUE": None - } - elif result is False: - return { - label+"_FALSE": None - } - else: - return { - label+"_UNKNOWN": None - } + return outcome_tag(label, check_equality(criterion, parameters_dict)) def equality_equivalence(unused_input): result = False @@ -282,14 +271,7 @@ def equality_equivalence(unused_input): else: symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) - if result is True: - return { - label+"_TRUE": None - } - else: - return { - label+"_FALSE": None - } + return outcome_tag(label, result is True) graph = CriteriaGraph(label) END = graph.add_end_node() @@ -303,14 +285,7 @@ def same_symbols(unused_input): lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols - if result is True: - return { - label+"_SAME_SYMBOLS"+"_TRUE": None - } - else: - return { - label+"_SAME_SYMBOLS"+"_FALSE": None - } + return outcome_tag(label+"_SAME_SYMBOLS", result is True) use_set_equivalence, response_list, answer_list = as_sets( parameters_dict["reserved_expressions"]["response"], @@ -332,46 +307,51 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", evaluate=equality_equivalence ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_UNKNOWN", + END, summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), details="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN") ) - graph.attach(label+"_UNKNOWN", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_TRUE", + END, summary=str(lhs)+" is equivalent to "+str(rhs), details=str(lhs)+" is equivalent to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") ) - graph.attach(label+"_TRUE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_FALSE", + END, summary=str(lhs)+" is not equivalent to "+str(rhs), details=str(lhs)+" is not equivalent to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") ) - graph.attach(label+"_FALSE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_EXPRESSION_NOT_EQUALITY", + END, summary=str(lhs)+" is an expression, not an equality.", details=str(lhs)+" is an expression, not an equality.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") ) - graph.attach(label+"_EXPRESSION_NOT_EQUALITY", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_EQUALITY_NOT_EXPRESSION", + END, summary=str(lhs)+" is an equality, not an expression.", details=str(lhs)+" is an equality, not an expression.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") ) - graph.attach(label+"_EQUALITY_NOT_EXPRESSION", END.label) else: graph.add_evaluation_node( label, @@ -393,38 +373,42 @@ def same_symbols(unused_input): details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", evaluate=same_symbols ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_SAME_SYMBOLS", label+"_SAME_SYMBOLS"+"_TRUE", + END, summary=str(lhs)+" has the same symbols as "+str(rhs), details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") ) - graph.attach(label+"_SAME_SYMBOLS"+"_TRUE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_SAME_SYMBOLS", label+"_SAME_SYMBOLS"+"_FALSE", + END, summary=str(lhs)+" does not have the same symbols as "+str(rhs), details=str(lhs)+" does note have the same (free) symbols as "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") ) - graph.attach(label+"_SAME_SYMBOLS"+"_FALSE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_FALSE", + END, summary=str(lhs)+"=\\="+str(rhs), details=str(lhs)+" is not equal to"+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") ) - graph.attach(label+"_FALSE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_UNKNOWN", + END, summary="Cannot detrmine if "+str(lhs)+"="+str(rhs), details="Cannot detrmine if "+str(lhs)+" is equal to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("UNKNOWN") ) - graph.attach(label+"_UNKNOWN", END.label) return graph @@ -549,14 +533,7 @@ def criterion_where_node(criterion, parameters_dict, label=None): def create_expression_check(crit): def expression_check(unused_input): result = check_equality(crit, parameters_dict, local_substitutions=local_subs) - if result is True: - return { - label+"_TRUE": None - } - else: - return { - label+"_FALSE": None - } + return outcome_tag(label, result is True) return expression_check graph = CriteriaGraph(label) @@ -567,14 +544,15 @@ def expression_check(unused_input): details="Checks if "+expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+".", evaluate=create_expression_check(expression) ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_TRUE", + END, summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), details=expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+"is true.", feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") ) - graph.attach(label+"_TRUE", END.label) graph.attach_new_criterion( label, label+"_FALSE", @@ -634,14 +612,15 @@ def identify_reason(unused_input): details="Attempt to identify why the response is incorrect.", evaluate=identify_reason ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_IDENTIFY_REASON", label+"_UNKNOWN", + END, summary="Unknown reason", details="No candidates for how the response was computed were found.", feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) - graph.attach(label+"_UNKNOWN", END.label) def get_candidates(unused_input): candidates = { @@ -671,16 +650,14 @@ def get_candidates(unused_input): if group_label != "UNKNOWN": group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label+"_GET_CANDIDATES_"+group_label, label+"_RESPONSE_CANDIDATES_"+group_label, + END, summary="response = "+str(value), details="Response candidates: "+expressions_string ) - graph.attach( - label+"_RESPONSE_CANDIDATES_"+group_label, - END.label - ) return graph @@ -689,48 +666,39 @@ def criterion_eval_node(criterion, parameters_dict, generate_feedback=True): def evaluation_node_internal(unused_input): result = check_criterion(criterion, parameters_dict, generate_feedback) - label = criterion.content_string() - if result is True: - return { - label+"_TRUE": feedback_string_generator_inputs - } - elif result is False: - return { - label+"_FALSE": feedback_string_generator_inputs - } - else: - return { - label+"_UNKNOWN": feedback_string_generator_inputs - } + return outcome_tag(criterion.content_string(), result, feedback_string_generator_inputs) label = criterion.content_string() graph = CriteriaGraph(label) END = graph.add_end_node() graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_TRUE", + END, summary="True", details=label+" is true.", feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") ) - graph.attach(label+"_TRUE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_FALSE", + END, summary="True", details=label+" is false.", feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") ) - graph.attach(label+"_FALSE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_UNKNOWN", + END, summary="True", details=label+" is false.", feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") ) - graph.attach(label+"_UNKNOWN", END.label) return graph @@ -745,9 +713,7 @@ def feedback_procedure_generator(parameters_dict): } graph_template = graph_templates.get(criterion.label, criterion_eval_node) graph = graph_template(criterion, parameters_dict) - for evaluation in graph.evaluations.values(): - if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END_LABEL + disable_nodes(graph, parameters_dict.get("disabled_evaluation_nodes", set())) graphs.update({label: graph}) return graphs diff --git a/app/context/symbolic/set_equivalence.py b/app/context/symbolic/set_equivalence.py index 3a264d1..217a0f3 100644 --- a/app/context/symbolic/set_equivalence.py +++ b/app/context/symbolic/set_equivalence.py @@ -1,6 +1,7 @@ from sympy import Equality from ...feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ..graph_helpers import attach_terminal_criterion def as_sets(response, answer): @@ -76,27 +77,30 @@ def set_equivalence(unused_input): details=str(lhs)+" is equal to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_MULTIPLE_ANSWER_FAIL_ALL", + END, summary=str(lhs)+" is not equal to "+str(rhs), details="At least one answer or response was incorrect.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", + END, summary="Unexpected element in response.", details="At least one response was incorrect.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE") ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label) - graph.attach_new_criterion( + attach_terminal_criterion( + graph, label, label+"_MULTIPLE_ANSWER_FAIL_ANSWER", + END, summary="Missing element in response", details="At least one answer is missing in the response.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ANSWER") ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ANSWER", END.label) From 7d5f9c45bb438de64fc09d0456714f6afdeef59a Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 12:06:10 +0100 Subject: [PATCH 06/25] Refactor symbolic context: remove `set_equivalence.py`, add `graphs` package with specialized modules (`equivalence`, `set_equivalence`, `generic`, `substitution`), and extract comparison logic into `comparison.py`. --- app/context/symbolic/__init__.py | 686 +----------------- app/context/symbolic/comparison.py | 163 +++++ app/context/symbolic/graphs/__init__.py | 1 + app/context/symbolic/graphs/equivalence.py | 195 +++++ app/context/symbolic/graphs/generic.py | 46 ++ .../symbolic/{ => graphs}/set_equivalence.py | 4 +- app/context/symbolic/graphs/substitution.py | 173 +++++ .../symbolic/graphs/syntactical_form.py | 117 +++ app/context/symbolic/variations.py | 59 ++ 9 files changed, 767 insertions(+), 677 deletions(-) create mode 100644 app/context/symbolic/comparison.py create mode 100644 app/context/symbolic/graphs/__init__.py create mode 100644 app/context/symbolic/graphs/equivalence.py create mode 100644 app/context/symbolic/graphs/generic.py rename app/context/symbolic/{ => graphs}/set_equivalence.py (96%) create mode 100644 app/context/symbolic/graphs/substitution.py create mode 100644 app/context/symbolic/graphs/syntactical_form.py create mode 100644 app/context/symbolic/variations.py diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 30a9248..000b46a 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -1,23 +1,18 @@ -from sympy import Add, Pow, Mul, Equality, im, I -from sympy import re as real_part - from lambdafeedback.expression_parsing import ( ExpressionParams, SympyParsingConfig, - generate_arbitrary_number_pattern_matcher, - parse_expression, parse_expressions, preprocess_expression, ) from ...preview.symbolic import preview_function -from .numerical import compare_numerically -from .set_equivalence import add_set_equivalence_nodes, as_sets -from ...feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ...feedback.syntactical_forms import PATTERNS as syntactical_forms -from ..graph_helpers import attach_terminal_criterion, disable_nodes, outcome_tag +from ..graph_helpers import disable_nodes +from .graphs.equivalence import criterion_equality_node +from .graphs.generic import criterion_eval_node +from .graphs.substitution import criterion_where_node +from .graphs.syntactical_form import criterion_written_as_node -from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser +from lambdafeedback.criteria import build_criteria_parser default_parameters = { @@ -41,676 +36,17 @@ def expression_parse(name, expr, parameters, evaluation_result): default_criteria = ["response = answer"] -def unsimplified_with_unsplittable(parsing_params, symbols): - """`parsing_params` without simplification, with `symbols` also read as single symbols.""" - return parsing_params.replace( - simplify=False, - extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(symbols), - ) - - -def check_criterion(criterion, parameters_dict, generate_feedback=True): - label = criterion.label.strip() - parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) - if label in {"EQUALITY", "WRITTEN_AS"}: - result = check_equality(criterion, parameters_dict) - elif label == "ORDER": - result = check_order(criterion, parameters_dict) - elif label == "CONTAINS": - result = check_contains_symbol(criterion, parameters_dict) - elif label == "PROPORTIONAL_TO": - result = check_proportionality(criterion, parameters_dict) - elif label == "WHERE": - crit = criterion.children[0] - subs = criterion.children[1] - local_subs = [] - if subs.label == "EQUALITY": - subs = [subs] - elif subs.label == "SEPARATOR": - subs = subs.children - for sub in subs: - name = sub.children[0].content_string() - expr = parse_expression(sub.children[1].content_string(), parsing_params) - local_subs.append((name, expr)) - result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}, generate_feedback) - else: - raise Exception(label) - return result - - -def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): - parsing_params = parameters_dict["parsing_parameters"] - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) - lhs = criterion.children[0].content_string() - rhs = criterion.children[1].content_string() - lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) - rhs_expr = parse_expression(rhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) - if parsing_params.params.complex_numbers: - simplified_lhs_expr = lhs_expr.cancel().simplify() - simplified_rhs_expr = rhs_expr.cancel().simplify() - if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): - lhs_expr = real_part(simplified_lhs_expr) + I*im(simplified_lhs_expr) - rhs_expr = real_part(simplified_rhs_expr) + I*im(simplified_rhs_expr) - return lhs_expr, rhs_expr - - -def do_comparison_infinite(comparison_symbol, lhs_expr, rhs_expr): - # When either side is infinite, subtracting (e.g. oo - oo) yields nan, making - # the subtraction-based do_comparison unreliable. Compare the sides directly instead. - direct_comparisons = { - "=": lhs_expr == rhs_expr, - ">": lhs_expr > rhs_expr, - ">=": lhs_expr >= rhs_expr, - "<": lhs_expr < rhs_expr, - "<=": lhs_expr <= rhs_expr, - } - try: - return bool(direct_comparisons[comparison_symbol.strip()]) - except Exception: - return None - - -def do_comparison(comparison_symbol, expression): - comparisons = { - "=": lambda expr: bool(expression.cancel().simplify().simplify() == 0), - ">": lambda expr: bool(expression.cancel().simplify().simplify() > 0), - ">=": lambda expr: bool(expression.cancel().simplify().simplify() >= 0), - "<": lambda expr: bool(expression.cancel().simplify().simplify() < 0), - "<=": lambda expr: bool(expression.cancel().simplify().simplify() <= 0), - } - comparison = comparisons[comparison_symbol.strip()] - try: - result = comparison(expression) - except Exception: - result = None - return result - - -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): - result = False - elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality): - result = False - else: - try: - either_is_infinite = lhs_expr.is_infinite or rhs_expr.is_infinite - except (KeyError, TypeError): - either_is_infinite = False - if either_is_infinite: - result = do_comparison_infinite(criterion.content, lhs_expr, rhs_expr) - else: - result = do_comparison(criterion.content, lhs_expr-rhs_expr) - # There are some types of expression, e.g. those containing hyperbolic trigonometric functions, that can behave - # unpredictably when simplification is applied. For that reason we check several different combinations of - # simplifications here in order to reduce the likelihood of false negatives. - if result is False: - result = do_comparison(criterion.content, lhs_expr-rhs_expr.simplify()) - if result is False: - result = do_comparison(criterion.content, lhs_expr.simplify()-rhs_expr) - if result is False: - result = do_comparison(criterion.content, lhs_expr.simplify()-rhs_expr.simplify()) - - if result is False: - result = compare_numerically(lhs_expr, rhs_expr, parameters_dict) - - return result - - -def check_order(criterion, parameters_dict, local_substitutions=[]): - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) - result = do_comparison(criterion.content, lhs_expr-rhs_expr) - return result - - -def check_proportionality(criterion, parameters_dict, local_substitutions=[]): - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) - result = None - if lhs_expr.cancel().simplify().simplify() != 0: - result = (rhs_expr/lhs_expr).cancel().simplify() - elif rhs_expr.cancel().simplify().simplify() != 0: - result = (lhs_expr/rhs_expr).cancel().simplify() - if result == 0 or result is None: - result = False - else: - result = result.is_constant() - return result - - -def check_contains_symbol(criterion, parameters_dict, local_substitutions=[]): - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) - result = rhs_expr in lhs_expr.atoms() - return result - - -def find_coords_for_node_type(expression, node_type): - stack = [(expression, tuple())] - node_coords = [] - while len(stack) > 0: - (expr, coord) = stack.pop() - if isinstance(expr, node_type): - node_coords.append(coord) - for (k, arg) in enumerate(expr.args): - stack.append((arg, coord+(k,))) - return node_coords - - -def replace_node_variations(expression, type_of_node, replacement_function): - variations = [] - list_of_coords = find_coords_for_node_type(expression, type_of_node) - for coords in list_of_coords: - nodes = [expression] - for coord in coords: - nodes.append(nodes[-1].args[coord]) - for k in range(0, len(nodes[-1].args)): - variation = replacement_function(nodes[-1], k) - for (node, coord) in reversed(list(zip(nodes, coords))): - new_args = node.args[0:coord]+(variation,)+node.args[coord+1:] - variation = type(node)(*new_args) - variations.append(variation) - return variations - - -def one_addition_to_subtraction(expression): - def addition_to_subtraction(node, k): - return node - 2*node.args[k] - variations = replace_node_variations(expression, Add, addition_to_subtraction) - return variations - - -def one_swap_addition_and_multiplication(expression): - - def addition_to_multiplication(node, k): - return node - node.args[k-1] - node.args[k] + node.args[k-1] * node.args[k] - - def multiplication_to_addition(node, k): - return node - 2*node.args[k] - - variations = replace_node_variations(expression, Add, addition_to_multiplication) - variations += replace_node_variations(expression, Mul, addition_to_multiplication) - - return variations - - -def one_exponent_flip(expression): - def exponent_flip(node, k): - return node**(-1) - variations = replace_node_variations(expression, Pow, exponent_flip) - return variations - - -def criterion_equality_node(criterion, parameters_dict, label=None): - if label is None: - label = criterion.content_string() - - def mathematical_equivalence(unused_input): - return outcome_tag(label, check_equality(criterion, parameters_dict)) - - def equality_equivalence(unused_input): - result = False - res = parameters_dict["reserved_expressions"]["response"] - ans = parameters_dict["reserved_expressions"]["answer"] - - if (not isinstance(res, Equality)) and isinstance(ans, Equality): - return { - label+"_EXPRESSION_NOT_EQUALITY": None - } - - if isinstance(res, Equality) and (not isinstance(ans, Equality)): - return { - label+"_EQUALITY_NOT_EXPRESSION": None - } - - # TODO: Remove when criteria for checking proportionality is implemented - if isinstance(res, Equality) and isinstance(ans, Equality): - if (res.args[0]-res.args[1]).simplify() == 0: - symbols_in_equality_ratio = (ans.args[0]-ans.args[1]).simplify().free_symbols - elif (ans.args[0]-ans.args[1]).simplify() == 0: - symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols - else: - symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) - return outcome_tag(label, result is True) - - graph = CriteriaGraph(label) - END = graph.add_end_node() - lhs = criterion.children[0].content_string() - rhs = criterion.children[1].content_string() - - def same_symbols(unused_input): - parsing_params = parameters_dict["parsing_parameters"] - local_substitutions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) - lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) - rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) - result = lsym.free_symbols == rsym.free_symbols - return outcome_tag(label+"_SAME_SYMBOLS", result is True) - - use_set_equivalence, response_list, answer_list = as_sets( - parameters_dict["reserved_expressions"]["response"], - parameters_dict["reserved_expressions"]["answer"], - ) - - res = parameters_dict["reserved_expressions"]["response"] - ans = parameters_dict["reserved_expressions"]["answer"] - use_equality_equivalence = isinstance(res, Equality) or isinstance(ans, Equality) - - if use_set_equivalence is True: - add_set_equivalence_nodes( - graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list, check_equality - ) - elif use_equality_equivalence is True: - graph.add_evaluation_node( - label, - summary=label, - details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", - evaluate=equality_equivalence - ) - attach_terminal_criterion( - graph, - label, - label+"_UNKNOWN", - END, - summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), - details="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN") - ) - attach_terminal_criterion( - graph, - label, - label+"_TRUE", - END, - summary=str(lhs)+" is equivalent to "+str(rhs), - details=str(lhs)+" is equivalent to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") - ) - attach_terminal_criterion( - graph, - label, - label+"_FALSE", - END, - summary=str(lhs)+" is not equivalent to "+str(rhs), - details=str(lhs)+" is not equivalent to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") - ) - attach_terminal_criterion( - graph, - label, - label+"_EXPRESSION_NOT_EQUALITY", - END, - summary=str(lhs)+" is an expression, not an equality.", - details=str(lhs)+" is an expression, not an equality.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") - ) - attach_terminal_criterion( - graph, - label, - label+"_EQUALITY_NOT_EXPRESSION", - END, - summary=str(lhs)+" is an equality, not an expression.", - details=str(lhs)+" is an equality, not an expression.", - feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") - ) - else: - graph.add_evaluation_node( - label, - summary=label, - details="Checks if "+str(lhs)+"="+str(rhs)+".", - evaluate=mathematical_equivalence - ) - graph.attach_new_criterion( - label, - label+"_TRUE", - summary=str(lhs)+"="+str(rhs), - details=str(lhs)+" is equal to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") - ) - graph.attach_new_evaluation( - label+"_TRUE", - label+"_SAME_SYMBOLS", - summary=str(lhs)+" has the same symbols as "+str(rhs), - details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", - evaluate=same_symbols - ) - attach_terminal_criterion( - graph, - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_TRUE", - END, - summary=str(lhs)+" has the same symbols as "+str(rhs), - details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") - ) - attach_terminal_criterion( - graph, - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_FALSE", - END, - summary=str(lhs)+" does not have the same symbols as "+str(rhs), - details=str(lhs)+" does note have the same (free) symbols as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") - ) - attach_terminal_criterion( - graph, - label, - label+"_FALSE", - END, - summary=str(lhs)+"=\\="+str(rhs), - details=str(lhs)+" is not equal to"+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") - ) - attach_terminal_criterion( - graph, - label, - label+"_UNKNOWN", - END, - summary="Cannot detrmine if "+str(lhs)+"="+str(rhs), - details="Cannot detrmine if "+str(lhs)+" is equal to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("UNKNOWN") - ) - return graph - - -def criterion_written_as_node(criterion, parameters_dict, label=None): - if label is None: - label = criterion.content_string() - - def written_as_known_form(string, name): - def find_form(unused_input): - forms = set() - for form_label in syntactical_forms.keys(): - if syntactical_forms[form_label].matcher(string) is True: - forms.add(form_label) - if len(forms) > 0: - return {name+"_WRITTEN_AS_"+form_label: None for form_label in forms} - else: - return {name+"_WRITTEN_AS_UNKNOWN": None} - return find_form - - def compare_to_form(string, form_label): - def inner(unused_input): - result = dict() - if syntactical_forms[form_label].matcher(string) is True: - result.update({label+"_TRUE": None}) - else: - result.update({label+"_FALSE": None}) - return result - return inner - - graph = CriteriaGraph(label) - graph.add_end_node() - - lhs = criterion.children[0].content_string() - rhs = criterion.children[1].content_string() - - if set([lhs, rhs]) == set(["response", "answer"]): - - # TODO: Make a version that can compare the forms of any two reserved expressions - answer = parameters_dict["reserved_expressions_strings"]["task"]["answer"] - response = parameters_dict["reserved_expressions_strings"]["learner"]["response"] - - def response_written_as_answer(unused_input): - matcher = generate_arbitrary_number_pattern_matcher(answer) - if matcher(response): - return {label+"_TRUE": None} - else: - return {label+"_FALSE": None} - - graph.add_evaluation_node( - str(rhs)+"_WRITTEN_AS_KNOWN_FORM", - summary=label, - details=f"Checks if {rhs} is written in a known form.", - evaluate=written_as_known_form(answer, rhs) - ) - graph.attach_new_criterion( - str(rhs)+"_WRITTEN_AS_KNOWN_FORM", - str(rhs)+"_WRITTEN_AS_UNKNOWN", - summary=str(rhs)+" is not written in a known form.", - details=str(rhs)+" is not written in a known form.", - feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"]("UNKNOWN") - ) - graph.attach_new_evaluation( - str(rhs)+"_WRITTEN_AS_UNKNOWN", - label, - summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".", - details="Checks if "+str(lhs)+" is written in the same form as "+str(rhs)+".", - evaluate=response_written_as_answer, - sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"} - ) - graph.attach_new_criterion( - label, - label+"_TRUE", - summary=str(rhs)+" written in the same form as "+str(rhs)+".", - details=str(rhs)+" written in the same form as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("TRUE") - ) - graph.attach_new_criterion( - label, - label+"_FALSE", - summary=str(rhs)+" written in the same form as "+str(rhs)+".", - details=str(rhs)+" written in the same form as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("FALSE") - ) - - for form_label in syntactical_forms.keys(): - graph.attach_new_criterion( - str(rhs)+"_WRITTEN_AS_KNOWN_FORM", - str(rhs)+"_WRITTEN_AS_"+form_label, - summary=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", - details=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", - feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"](form_label), - ) - graph.attach_new_evaluation( - str(rhs)+"_WRITTEN_AS_"+form_label, - str(lhs)+"_COMPARE_FORM_TO_"+form_label, - summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label].name+" form.", - details="Checks if "+str(lhs)+" is written in "+syntactical_forms[form_label].name+" form.", - evaluate=compare_to_form(response, form_label), - sufficiencies={str(rhs)+"_WRITTEN_AS_"+form_label} - ) - graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_TRUE") - graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") - return graph - - -def criterion_where_node(criterion, parameters_dict, label=None): - parsing_params = parameters_dict["parsing_parameters"] - expression = criterion.children[0] - subs = criterion.children[1] - local_subs = [] - if subs.label == "EQUALITY": - subs = [subs] - elif subs.label == "SEPARATOR": - subs = subs.children - for sub in subs: - name = sub.children[0].content_string() - expr = parse_expression(sub.children[1].content_string(), parsing_params) - local_subs.append((name, expr)) - if label is None: - label = criterion.content_string() - - def create_expression_check(crit): - def expression_check(unused_input): - result = check_equality(crit, parameters_dict, local_substitutions=local_subs) - return outcome_tag(label, result is True) - return expression_check - - graph = CriteriaGraph(label) - END = graph.add_end_node() - graph.add_evaluation_node( - label, - summary=label, - details="Checks if "+expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+".", - evaluate=create_expression_check(expression) - ) - attach_terminal_criterion( - graph, - label, - label+"_TRUE", - END, - summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), - details=expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+"is true.", - feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") - ) - graph.attach_new_criterion( - label, - label+"_FALSE", - summary="not "+expression.content_string(), - details=expression.content_string()+" is not true when "+", ".join([s.content_string() for s in subs])+".", - feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("FALSE") - ) - - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - response = parameters_dict["reserved_expressions"]["response"] - expression_to_vary = None - if expression.children[0].content_string().strip() == "response": - expression_to_vary = expression.children[1] - elif expression.children[1].content_string().strip() == "response": - expression_to_vary = expression.children[0] - if expression_to_vary is not None and "response" in expression_to_vary.content_string(): - expression_to_vary = None - if expression_to_vary is not None: - response_value = response.subs(local_subs) - expression_to_vary = parse_expression(expression_to_vary.content_string(), parsing_params).subs(reserved_expressions) - variation_groups = { - "ONE_ADDITION_TO_SUBTRACTION": { - "variations": one_addition_to_subtraction(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" if one addition is changed to a subtraction or vice versa.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), - }, - "ONE_EXPONENT_FLIP": { - "variations": one_exponent_flip(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one exponent has its sign changed.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), - }, - "ONE_SWAP_ADDITION_AND_MULTIPLICATION": { - "variations": one_swap_addition_and_multiplication(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one addition is replaced with a multiplication or vice versa.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), - } - } - value = expression_to_vary.subs(local_subs).simplify() - values_and_expressions = {str(value): set([expression_to_vary])} - values_and_variations_group = {str(value): set(["UNKNOWN"])} - for (group_label, info) in variation_groups.items(): - for variation in info["variations"]: - value = variation.subs(local_subs).simplify() - values_and_expressions.update({str(value): values_and_expressions.get(str(value), set()).union(set([variation]))}) - if value != expression_to_vary.subs(local_subs): - values_and_variations_group.update({str(value): values_and_variations_group.get(str(value), set()).union(set([group_label]))}) - if len(values_and_expressions) > 1: - def identify_reason(unused_input): - reasons = { - label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) - } - return reasons - graph.attach_new_evaluation( - label+"_FALSE", - label+"_IDENTIFY_REASON", - summary="Identify reason.", - details="Attempt to identify why the response is incorrect.", - evaluate=identify_reason - ) - attach_terminal_criterion( - graph, - label+"_IDENTIFY_REASON", - label+"_UNKNOWN", - END, - summary="Unknown reason", - details="No candidates for how the response was computed were found.", - feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") - ) - - def get_candidates(unused_input): - candidates = { - label+"_RESPONSE_CANDIDATES_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] - } - return candidates - - for (group_label, group_info) in variation_groups.items(): - graph.attach_new_criterion( - label+"_IDENTIFY_REASON", - label+"_"+group_label, - summary=group_info["summary"](expression_to_vary, group_info["variations"]), - details=group_info["details"](expression_to_vary, group_info["variations"]), - feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") - ) - graph.attach_new_evaluation( - label+"_"+group_label, - label+"_GET_CANDIDATES_"+group_label, - summary="Get candidate responses that satisfy "+expression.content_string(), - details="Get candidate responses that satisfy "+expression.content_string(), - evaluate=get_candidates - ) - - for (value, expressions) in values_and_expressions.items(): - expressions_string = ", ".join([str(e) for e in expressions]) - for group_label in values_and_variations_group[value]: - if group_label != "UNKNOWN": - group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] - if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: - attach_terminal_criterion( - graph, - label+"_GET_CANDIDATES_"+group_label, - label+"_RESPONSE_CANDIDATES_"+group_label, - END, - summary="response = "+str(value), - details="Response candidates: "+expressions_string - ) - return graph - - -def criterion_eval_node(criterion, parameters_dict, generate_feedback=True): - feedback_string_generator_inputs = {'criterion': criterion} - - def evaluation_node_internal(unused_input): - result = check_criterion(criterion, parameters_dict, generate_feedback) - return outcome_tag(criterion.content_string(), result, feedback_string_generator_inputs) - - label = criterion.content_string() - graph = CriteriaGraph(label) - END = graph.add_end_node() - graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) - attach_terminal_criterion( - graph, - label, - label+"_TRUE", - END, - summary="True", - details=label+" is true.", - feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") - ) - attach_terminal_criterion( - graph, - label, - label+"_FALSE", - END, - summary="True", - details=label+" is false.", - feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") - ) - attach_terminal_criterion( - graph, - label, - label+"_UNKNOWN", - END, - summary="True", - details=label+" is false.", - feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") - ) - return graph +graph_templates = { + "EQUALITY": criterion_equality_node, + "WHERE": criterion_where_node, + "WRITTEN_AS": criterion_written_as_node, +} def feedback_procedure_generator(parameters_dict): graphs = dict() criteria = set(parameters_dict["criteria"].items()) for (label, criterion) in criteria: - graph_templates = { - "EQUALITY": criterion_equality_node, - "WHERE": criterion_where_node, - "WRITTEN_AS": criterion_written_as_node, - } graph_template = graph_templates.get(criterion.label, criterion_eval_node) graph = graph_template(criterion, parameters_dict) disable_nodes(graph, parameters_dict.get("disabled_evaluation_nodes", set())) diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py new file mode 100644 index 0000000..401f28e --- /dev/null +++ b/app/context/symbolic/comparison.py @@ -0,0 +1,163 @@ +from sympy import Equality, im, I +from sympy import re as real_part + +from lambdafeedback.expression_parsing import parse_expression + +from .numerical import compare_numerically + + +def unsimplified_with_unsplittable(parsing_params, symbols): + """`parsing_params` without simplification, with `symbols` also read as single symbols.""" + return parsing_params.replace( + simplify=False, + extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(symbols), + ) + + +def where_substitution_nodes(subs_node): + """The individual substitutions in the substitution part of a WHERE criterion.""" + if subs_node.label == "EQUALITY": + return [subs_node] + elif subs_node.label == "SEPARATOR": + return subs_node.children + return subs_node + + +def parse_where_substitutions(subs_node, parsing_params): + """`[(name, expression), ...]` for each substitution in the substitution part of a WHERE criterion.""" + local_subs = [] + for sub in where_substitution_nodes(subs_node): + name = sub.children[0].content_string() + expr = parse_expression(sub.children[1].content_string(), parsing_params) + local_subs.append((name, expr)) + return local_subs + + +def check_criterion(criterion, parameters_dict, generate_feedback=True): + label = criterion.label.strip() + parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) + if label in {"EQUALITY", "WRITTEN_AS"}: + result = check_equality(criterion, parameters_dict) + elif label == "ORDER": + result = check_order(criterion, parameters_dict) + elif label == "CONTAINS": + result = check_contains_symbol(criterion, parameters_dict) + elif label == "PROPORTIONAL_TO": + result = check_proportionality(criterion, parameters_dict) + elif label == "WHERE": + crit = criterion.children[0] + local_subs = parse_where_substitutions(criterion.children[1], parsing_params) + result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}, generate_feedback) + else: + raise Exception(label) + return result + + +def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): + parsing_params = parameters_dict["parsing_parameters"] + reserved_expressions = list(parameters_dict["reserved_expressions"].items()) + parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) + lhs = criterion.children[0].content_string() + rhs = criterion.children[1].content_string() + lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) + rhs_expr = parse_expression(rhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) + if parsing_params.params.complex_numbers: + simplified_lhs_expr = lhs_expr.cancel().simplify() + simplified_rhs_expr = rhs_expr.cancel().simplify() + if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): + lhs_expr = real_part(simplified_lhs_expr) + I*im(simplified_lhs_expr) + rhs_expr = real_part(simplified_rhs_expr) + I*im(simplified_rhs_expr) + return lhs_expr, rhs_expr + + +def do_comparison_infinite(comparison_symbol, lhs_expr, rhs_expr): + # When either side is infinite, subtracting (e.g. oo - oo) yields nan, making + # the subtraction-based do_comparison unreliable. Compare the sides directly instead. + direct_comparisons = { + "=": lhs_expr == rhs_expr, + ">": lhs_expr > rhs_expr, + ">=": lhs_expr >= rhs_expr, + "<": lhs_expr < rhs_expr, + "<=": lhs_expr <= rhs_expr, + } + try: + return bool(direct_comparisons[comparison_symbol.strip()]) + except Exception: + return None + + +def do_comparison(comparison_symbol, expression): + comparisons = { + "=": lambda expr: bool(expression.cancel().simplify().simplify() == 0), + ">": lambda expr: bool(expression.cancel().simplify().simplify() > 0), + ">=": lambda expr: bool(expression.cancel().simplify().simplify() >= 0), + "<": lambda expr: bool(expression.cancel().simplify().simplify() < 0), + "<=": lambda expr: bool(expression.cancel().simplify().simplify() <= 0), + } + comparison = comparisons[comparison_symbol.strip()] + try: + result = comparison(expression) + except Exception: + result = None + return result + + +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): + return False + elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality): + return False + + try: + either_is_infinite = lhs_expr.is_infinite or rhs_expr.is_infinite + except (KeyError, TypeError): + either_is_infinite = False + if either_is_infinite: + result = do_comparison_infinite(criterion.content, lhs_expr, rhs_expr) + else: + result = do_comparison(criterion.content, lhs_expr-rhs_expr) + + # There are some types of expression, e.g. those containing hyperbolic trigonometric functions, that can behave + # unpredictably when simplification is applied. For that reason we check several different combinations of + # simplifications here in order to reduce the likelihood of false negatives. + differences_to_retry = ( + lambda: lhs_expr-rhs_expr.simplify(), + lambda: lhs_expr.simplify()-rhs_expr, + lambda: lhs_expr.simplify()-rhs_expr.simplify(), + ) + for difference in differences_to_retry: + if result is not False: + break + result = do_comparison(criterion.content, difference()) + + if result is False: + result = compare_numerically(lhs_expr, rhs_expr, parameters_dict) + + return result + + +def check_order(criterion, parameters_dict, local_substitutions=[]): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + result = do_comparison(criterion.content, lhs_expr-rhs_expr) + return result + + +def check_proportionality(criterion, parameters_dict, local_substitutions=[]): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + result = None + if lhs_expr.cancel().simplify().simplify() != 0: + result = (rhs_expr/lhs_expr).cancel().simplify() + elif rhs_expr.cancel().simplify().simplify() != 0: + result = (lhs_expr/rhs_expr).cancel().simplify() + if result == 0 or result is None: + result = False + else: + result = result.is_constant() + return result + + +def check_contains_symbol(criterion, parameters_dict, local_substitutions=[]): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + result = rhs_expr in lhs_expr.atoms() + return result diff --git a/app/context/symbolic/graphs/__init__.py b/app/context/symbolic/graphs/__init__.py new file mode 100644 index 0000000..e24f100 --- /dev/null +++ b/app/context/symbolic/graphs/__init__.py @@ -0,0 +1 @@ +"""Builders for the criteria graphs of the symbolic context, one module per kind of criterion.""" diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py new file mode 100644 index 0000000..a791abd --- /dev/null +++ b/app/context/symbolic/graphs/equivalence.py @@ -0,0 +1,195 @@ +from sympy import Equality + +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import parse_expression + +from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ..comparison import check_equality, unsimplified_with_unsplittable +from .set_equivalence import add_set_equivalence_nodes, as_sets + + +def _equality_equivalence(label, parameters_dict): + def equality_equivalence(unused_input): + result = False + res = parameters_dict["reserved_expressions"]["response"] + ans = parameters_dict["reserved_expressions"]["answer"] + + if (not isinstance(res, Equality)) and isinstance(ans, Equality): + return { + label+"_EXPRESSION_NOT_EQUALITY": None + } + + if isinstance(res, Equality) and (not isinstance(ans, Equality)): + return { + label+"_EQUALITY_NOT_EXPRESSION": None + } + + # TODO: Remove when criteria for checking proportionality is implemented + if isinstance(res, Equality) and isinstance(ans, Equality): + if (res.args[0]-res.args[1]).simplify() == 0: + symbols_in_equality_ratio = (ans.args[0]-ans.args[1]).simplify().free_symbols + elif (ans.args[0]-ans.args[1]).simplify() == 0: + symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols + else: + symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols + result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) + return outcome_tag(label, result is True) + return equality_equivalence + + +def _same_symbols(label, lhs, rhs, parameters_dict): + def same_symbols(unused_input): + parsing_params = parameters_dict["parsing_parameters"] + local_substitutions = list(parameters_dict["reserved_expressions"].items()) + parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) + lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) + rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) + result = lsym.free_symbols == rsym.free_symbols + return outcome_tag(label+"_SAME_SYMBOLS", result is True) + return same_symbols + + +def add_equality_equivalence_nodes(graph, END, parameters_dict, label, lhs, rhs): + """Adds the nodes that check if the equalities `lhs` and `rhs` are equivalent to `graph`.""" + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", + evaluate=_equality_equivalence(label, parameters_dict) + ) + attach_terminal_criterion( + graph, + label, + label+"_UNKNOWN", + END, + summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), + details="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN") + ) + attach_terminal_criterion( + graph, + label, + label+"_TRUE", + END, + summary=str(lhs)+" is equivalent to "+str(rhs), + details=str(lhs)+" is equivalent to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") + ) + attach_terminal_criterion( + graph, + label, + label+"_FALSE", + END, + summary=str(lhs)+" is not equivalent to "+str(rhs), + details=str(lhs)+" is not equivalent to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") + ) + attach_terminal_criterion( + graph, + label, + label+"_EXPRESSION_NOT_EQUALITY", + END, + summary=str(lhs)+" is an expression, not an equality.", + details=str(lhs)+" is an expression, not an equality.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") + ) + attach_terminal_criterion( + graph, + label, + label+"_EQUALITY_NOT_EXPRESSION", + END, + summary=str(lhs)+" is an equality, not an expression.", + details=str(lhs)+" is an equality, not an expression.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") + ) + + +def add_mathematical_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs): + """ + Adds the nodes that check if `lhs` and `rhs` are mathematically equal to `graph`, + and if they are, whether they have the same symbols. + """ + def mathematical_equivalence(unused_input): + return outcome_tag(label, check_equality(criterion, parameters_dict)) + + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+"="+str(rhs)+".", + evaluate=mathematical_equivalence + ) + graph.attach_new_criterion( + label, + label+"_TRUE", + summary=str(lhs)+"="+str(rhs), + details=str(lhs)+" is equal to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") + ) + graph.attach_new_evaluation( + label+"_TRUE", + label+"_SAME_SYMBOLS", + summary=str(lhs)+" has the same symbols as "+str(rhs), + details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", + evaluate=_same_symbols(label, lhs, rhs, parameters_dict) + ) + attach_terminal_criterion( + graph, + label+"_SAME_SYMBOLS", + label+"_SAME_SYMBOLS"+"_TRUE", + END, + summary=str(lhs)+" has the same symbols as "+str(rhs), + details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") + ) + attach_terminal_criterion( + graph, + label+"_SAME_SYMBOLS", + label+"_SAME_SYMBOLS"+"_FALSE", + END, + summary=str(lhs)+" does not have the same symbols as "+str(rhs), + details=str(lhs)+" does note have the same (free) symbols as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") + ) + attach_terminal_criterion( + graph, + label, + label+"_FALSE", + END, + summary=str(lhs)+"=\\="+str(rhs), + details=str(lhs)+" is not equal to"+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") + ) + attach_terminal_criterion( + graph, + label, + label+"_UNKNOWN", + END, + summary="Cannot detrmine if "+str(lhs)+"="+str(rhs), + details="Cannot detrmine if "+str(lhs)+" is equal to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("UNKNOWN") + ) + + +def criterion_equality_node(criterion, parameters_dict, label=None): + if label is None: + label = criterion.content_string() + + graph = CriteriaGraph(label) + END = graph.add_end_node() + lhs = criterion.children[0].content_string() + rhs = criterion.children[1].content_string() + + res = parameters_dict["reserved_expressions"]["response"] + ans = parameters_dict["reserved_expressions"]["answer"] + use_set_equivalence, response_list, answer_list = as_sets(res, ans) + + if use_set_equivalence is True: + add_set_equivalence_nodes( + graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list, check_equality + ) + elif isinstance(res, Equality) or isinstance(ans, Equality): + add_equality_equivalence_nodes(graph, END, parameters_dict, label, lhs, rhs) + else: + add_mathematical_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs) + return graph diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py new file mode 100644 index 0000000..4ca267b --- /dev/null +++ b/app/context/symbolic/graphs/generic.py @@ -0,0 +1,46 @@ +from lambdafeedback.criteria import CriteriaGraph + +from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ..comparison import check_criterion + + +def criterion_eval_node(criterion, parameters_dict, generate_feedback=True): + feedback_string_generator_inputs = {'criterion': criterion} + + def evaluation_node_internal(unused_input): + result = check_criterion(criterion, parameters_dict, generate_feedback) + return outcome_tag(criterion.content_string(), result, feedback_string_generator_inputs) + + label = criterion.content_string() + graph = CriteriaGraph(label) + END = graph.add_end_node() + graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) + attach_terminal_criterion( + graph, + label, + label+"_TRUE", + END, + summary="True", + details=label+" is true.", + feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") + ) + attach_terminal_criterion( + graph, + label, + label+"_FALSE", + END, + summary="True", + details=label+" is false.", + feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") + ) + attach_terminal_criterion( + graph, + label, + label+"_UNKNOWN", + END, + summary="True", + details=label+" is false.", + feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") + ) + return graph diff --git a/app/context/symbolic/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py similarity index 96% rename from app/context/symbolic/set_equivalence.py rename to app/context/symbolic/graphs/set_equivalence.py index 217a0f3..a3e4197 100644 --- a/app/context/symbolic/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -1,7 +1,7 @@ from sympy import Equality -from ...feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ..graph_helpers import attach_terminal_criterion +from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ...graph_helpers import attach_terminal_criterion def as_sets(response, answer): diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py new file mode 100644 index 0000000..2fef27d --- /dev/null +++ b/app/context/symbolic/graphs/substitution.py @@ -0,0 +1,173 @@ +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import parse_expression + +from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ..comparison import check_equality, parse_where_substitutions, where_substitution_nodes +from ..variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication + + +def _expression_to_vary(expression): + """The side of `expression` opposite `response`, or None if neither side is exactly `response`.""" + expression_to_vary = None + if expression.children[0].content_string().strip() == "response": + expression_to_vary = expression.children[1] + elif expression.children[1].content_string().strip() == "response": + expression_to_vary = expression.children[0] + if expression_to_vary is not None and "response" in expression_to_vary.content_string(): + expression_to_vary = None + return expression_to_vary + + +def _variation_groups(criterion, expression_to_vary): + return { + "ONE_ADDITION_TO_SUBTRACTION": { + "variations": one_addition_to_subtraction(expression_to_vary), + "summary": lambda expression, variations: criterion.children[0].content_string()+" if one addition is changed to a subtraction or vice versa.", + "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + }, + "ONE_EXPONENT_FLIP": { + "variations": one_exponent_flip(expression_to_vary), + "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one exponent has its sign changed.", + "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + }, + "ONE_SWAP_ADDITION_AND_MULTIPLICATION": { + "variations": one_swap_addition_and_multiplication(expression_to_vary), + "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one addition is replaced with a multiplication or vice versa.", + "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + } + } + + +def _group_values(expression_to_vary, variation_groups, local_subs): + """ + Groups `expression_to_vary` and its variations by their value after `local_subs` are applied. + Returns the expressions for each value, and the variation groups that produce each value. + """ + value = expression_to_vary.subs(local_subs).simplify() + values_and_expressions = {str(value): set([expression_to_vary])} + values_and_variations_group = {str(value): set(["UNKNOWN"])} + for (group_label, info) in variation_groups.items(): + for variation in info["variations"]: + value = variation.subs(local_subs).simplify() + values_and_expressions.update({str(value): values_and_expressions.get(str(value), set()).union(set([variation]))}) + if value != expression_to_vary.subs(local_subs): + values_and_variations_group.update({str(value): values_and_variations_group.get(str(value), set()).union(set([group_label]))}) + return values_and_expressions, values_and_variations_group + + +def _add_identify_reason_nodes(graph, END, label, criterion, expression, expression_to_vary, variation_groups, + values_and_expressions, values_and_variations_group, response_value): + """Adds the nodes that try to identify which variation of the expression the response matches.""" + def identify_reason(unused_input): + reasons = { + label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) + } + return reasons + graph.attach_new_evaluation( + label+"_FALSE", + label+"_IDENTIFY_REASON", + summary="Identify reason.", + details="Attempt to identify why the response is incorrect.", + evaluate=identify_reason + ) + attach_terminal_criterion( + graph, + label+"_IDENTIFY_REASON", + label+"_UNKNOWN", + END, + summary="Unknown reason", + details="No candidates for how the response was computed were found.", + feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") + ) + + def get_candidates(unused_input): + candidates = { + label+"_RESPONSE_CANDIDATES_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] + } + return candidates + + for (group_label, group_info) in variation_groups.items(): + graph.attach_new_criterion( + label+"_IDENTIFY_REASON", + label+"_"+group_label, + summary=group_info["summary"](expression_to_vary, group_info["variations"]), + details=group_info["details"](expression_to_vary, group_info["variations"]), + feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") + ) + graph.attach_new_evaluation( + label+"_"+group_label, + label+"_GET_CANDIDATES_"+group_label, + summary="Get candidate responses that satisfy "+expression.content_string(), + details="Get candidate responses that satisfy "+expression.content_string(), + evaluate=get_candidates + ) + + for (value, expressions) in values_and_expressions.items(): + expressions_string = ", ".join([str(e) for e in expressions]) + for group_label in values_and_variations_group[value]: + if group_label != "UNKNOWN": + group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] + if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: + attach_terminal_criterion( + graph, + label+"_GET_CANDIDATES_"+group_label, + label+"_RESPONSE_CANDIDATES_"+group_label, + END, + summary="response = "+str(value), + details="Response candidates: "+expressions_string + ) + + +def criterion_where_node(criterion, parameters_dict, label=None): + parsing_params = parameters_dict["parsing_parameters"] + expression = criterion.children[0] + subs = where_substitution_nodes(criterion.children[1]) + local_subs = parse_where_substitutions(criterion.children[1], parsing_params) + if label is None: + label = criterion.content_string() + + def expression_check(unused_input): + result = check_equality(expression, parameters_dict, local_substitutions=local_subs) + return outcome_tag(label, result is True) + + graph = CriteriaGraph(label) + END = graph.add_end_node() + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+".", + evaluate=expression_check + ) + attach_terminal_criterion( + graph, + label, + label+"_TRUE", + END, + summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), + details=expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+"is true.", + feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") + ) + graph.attach_new_criterion( + label, + label+"_FALSE", + summary="not "+expression.content_string(), + details=expression.content_string()+" is not true when "+", ".join([s.content_string() for s in subs])+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("FALSE") + ) + + expression_to_vary = _expression_to_vary(expression) + if expression_to_vary is None: + return graph + + reserved_expressions = list(parameters_dict["reserved_expressions"].items()) + response_value = parameters_dict["reserved_expressions"]["response"].subs(local_subs) + expression_to_vary = parse_expression(expression_to_vary.content_string(), parsing_params).subs(reserved_expressions) + variation_groups = _variation_groups(criterion, expression_to_vary) + values_and_expressions, values_and_variations_group = _group_values(expression_to_vary, variation_groups, local_subs) + if len(values_and_expressions) > 1: + _add_identify_reason_nodes( + graph, END, label, criterion, expression, expression_to_vary, variation_groups, + values_and_expressions, values_and_variations_group, response_value + ) + return graph diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py new file mode 100644 index 0000000..aaa8d89 --- /dev/null +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -0,0 +1,117 @@ +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import generate_arbitrary_number_pattern_matcher + +from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ....feedback.syntactical_forms import PATTERNS as syntactical_forms + + +def written_as_known_form(string, name): + def find_form(unused_input): + forms = set() + for form_label in syntactical_forms.keys(): + if syntactical_forms[form_label].matcher(string) is True: + forms.add(form_label) + if len(forms) > 0: + return {name+"_WRITTEN_AS_"+form_label: None for form_label in forms} + else: + return {name+"_WRITTEN_AS_UNKNOWN": None} + return find_form + + +def compare_to_form(string, form_label, label): + def inner(unused_input): + result = dict() + if syntactical_forms[form_label].matcher(string) is True: + result.update({label+"_TRUE": None}) + else: + result.update({label+"_FALSE": None}) + return result + return inner + + +def response_written_as_answer(response, answer, label): + def inner(unused_input): + matcher = generate_arbitrary_number_pattern_matcher(answer) + if matcher(response): + return {label+"_TRUE": None} + else: + return {label+"_FALSE": None} + return inner + + +def _add_known_form_nodes(graph, label, lhs, rhs, response): + """For each known form, adds nodes that check if `lhs` is written in that form when `rhs` is.""" + for form_label in syntactical_forms.keys(): + graph.attach_new_criterion( + str(rhs)+"_WRITTEN_AS_KNOWN_FORM", + str(rhs)+"_WRITTEN_AS_"+form_label, + summary=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", + details=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", + feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"](form_label), + ) + graph.attach_new_evaluation( + str(rhs)+"_WRITTEN_AS_"+form_label, + str(lhs)+"_COMPARE_FORM_TO_"+form_label, + summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label].name+" form.", + details="Checks if "+str(lhs)+" is written in "+syntactical_forms[form_label].name+" form.", + evaluate=compare_to_form(response, form_label, label), + sufficiencies={str(rhs)+"_WRITTEN_AS_"+form_label} + ) + graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_TRUE") + graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") + + +def criterion_written_as_node(criterion, parameters_dict, label=None): + if label is None: + label = criterion.content_string() + + graph = CriteriaGraph(label) + graph.add_end_node() + + lhs = criterion.children[0].content_string() + rhs = criterion.children[1].content_string() + + if set([lhs, rhs]) != set(["response", "answer"]): + return graph + + # TODO: Make a version that can compare the forms of any two reserved expressions + answer = parameters_dict["reserved_expressions_strings"]["task"]["answer"] + response = parameters_dict["reserved_expressions_strings"]["learner"]["response"] + + graph.add_evaluation_node( + str(rhs)+"_WRITTEN_AS_KNOWN_FORM", + summary=label, + details=f"Checks if {rhs} is written in a known form.", + evaluate=written_as_known_form(answer, rhs) + ) + graph.attach_new_criterion( + str(rhs)+"_WRITTEN_AS_KNOWN_FORM", + str(rhs)+"_WRITTEN_AS_UNKNOWN", + summary=str(rhs)+" is not written in a known form.", + details=str(rhs)+" is not written in a known form.", + feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"]("UNKNOWN") + ) + graph.attach_new_evaluation( + str(rhs)+"_WRITTEN_AS_UNKNOWN", + label, + summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".", + details="Checks if "+str(lhs)+" is written in the same form as "+str(rhs)+".", + evaluate=response_written_as_answer(response, answer, label), + sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"} + ) + graph.attach_new_criterion( + label, + label+"_TRUE", + summary=str(rhs)+" written in the same form as "+str(rhs)+".", + details=str(rhs)+" written in the same form as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("TRUE") + ) + graph.attach_new_criterion( + label, + label+"_FALSE", + summary=str(rhs)+" written in the same form as "+str(rhs)+".", + details=str(rhs)+" written in the same form as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("FALSE") + ) + _add_known_form_nodes(graph, label, lhs, rhs, response) + return graph diff --git a/app/context/symbolic/variations.py b/app/context/symbolic/variations.py new file mode 100644 index 0000000..6d375ad --- /dev/null +++ b/app/context/symbolic/variations.py @@ -0,0 +1,59 @@ +"""Variations of an expression with one operation changed, used to identify how an incorrect response was computed.""" + +from sympy import Add, Pow, Mul + + +def find_coords_for_node_type(expression, node_type): + stack = [(expression, tuple())] + node_coords = [] + while len(stack) > 0: + (expr, coord) = stack.pop() + if isinstance(expr, node_type): + node_coords.append(coord) + for (k, arg) in enumerate(expr.args): + stack.append((arg, coord+(k,))) + return node_coords + + +def replace_node_variations(expression, type_of_node, replacement_function): + variations = [] + list_of_coords = find_coords_for_node_type(expression, type_of_node) + for coords in list_of_coords: + nodes = [expression] + for coord in coords: + nodes.append(nodes[-1].args[coord]) + for k in range(0, len(nodes[-1].args)): + variation = replacement_function(nodes[-1], k) + for (node, coord) in reversed(list(zip(nodes, coords))): + new_args = node.args[0:coord]+(variation,)+node.args[coord+1:] + variation = type(node)(*new_args) + variations.append(variation) + return variations + + +def one_addition_to_subtraction(expression): + def addition_to_subtraction(node, k): + return node - 2*node.args[k] + variations = replace_node_variations(expression, Add, addition_to_subtraction) + return variations + + +def one_swap_addition_and_multiplication(expression): + + def addition_to_multiplication(node, k): + return node - node.args[k-1] - node.args[k] + node.args[k-1] * node.args[k] + + def multiplication_to_addition(node, k): + return node - 2*node.args[k] + + variations = replace_node_variations(expression, Add, addition_to_multiplication) + variations += replace_node_variations(expression, Mul, addition_to_multiplication) + + return variations + + +def one_exponent_flip(expression): + def exponent_flip(node, k): + return node**(-1) + variations = replace_node_variations(expression, Pow, exponent_flip) + return variations From bbf806856e68296d02b8b7866036450fd432f63e Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 12:10:43 +0100 Subject: [PATCH 07/25] Refactor physical_quantity context: split logic into separate modules (`graphs`, `parsing`, `criteria_parser`, `comparison`), update imports, and consolidate redundant code in `__init__.py`. --- app/context/physical_quantity/__init__.py | 444 +----------------- app/context/physical_quantity/comparison.py | 42 ++ .../physical_quantity/criteria_parser.py | 47 ++ .../physical_quantity/graphs/__init__.py | 1 + app/context/physical_quantity/graphs/match.py | 266 +++++++++++ app/context/physical_quantity/graphs/order.py | 76 +++ app/context/physical_quantity/parsing.py | 31 ++ 7 files changed, 478 insertions(+), 429 deletions(-) create mode 100644 app/context/physical_quantity/comparison.py create mode 100644 app/context/physical_quantity/criteria_parser.py create mode 100644 app/context/physical_quantity/graphs/__init__.py create mode 100644 app/context/physical_quantity/graphs/match.py create mode 100644 app/context/physical_quantity/graphs/order.py create mode 100644 app/context/physical_quantity/parsing.py diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index 1665e97..468b909 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -1,447 +1,33 @@ -# ------- -# IMPORTS -# ------- - from copy import deepcopy -from lambdafeedback.criteria import CriteriaGraph -from lambdafeedback.expression_parsing import ( - ExpressionParams, - SympyParsingConfig, - compute_relative_tolerance_from_significant_decimals, - parse_expression, -) + +from lambdafeedback.expression_parsing import ExpressionParams, SympyParsingConfig from lambdafeedback.units import QuantityParams, preprocess_quantity -from lambdafeedback.units import parse_quantity as parse_physical_quantity + from ...preview.physical_quantity import preview_function -from ...feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from ...feedback.physical_quantity import reverted_unit_feedback from ..symbolic import default_parameters as symbolic_default_parameters -from ..graph_helpers import attach_terminal_criterion, disable_nodes, outcome_tag -from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate -from sympy import Symbol +from ..graph_helpers import disable_nodes +from .criteria_parser import generate_criteria_parser +from .graphs.match import criterion_match_node +from .graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node +from .parsing import parse_quantity def expression_preprocess(name, expr, parameters): return preprocess_quantity(expr, QuantityParams.from_dict(parameters), name=name) -def parse_quantity(name, expr, parameters, evaluation_result): - quantity = parse_physical_quantity(expr, parameters["quantity"], name) - for index, reverted in enumerate(quantity.reverted_units): - evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) - if quantity.standard_values is None: - standard_value = None - elif len(quantity.standard_values) == 1: - standard_value = quantity.standard_values[0] - else: - standard_value = set(quantity.standard_values) - if quantity.value is not None: - value = parse_expression(quantity.value.content_string(), quantity.parsing_config) - else: - value = None - if quantity.standard_unit is not None: - standard_unit = quantity.standard_unit - else: - standard_unit = None - if quantity.unit is not None: - unit = parse_expression(quantity.unit.content_string(), quantity.parsing_config) - else: - unit = None - quantity_dict = { - "quantity": quantity, - "standard": { - "value": standard_value, - "unit": standard_unit - }, - "dimension": quantity.dimension, - "original": { - "value": value, - "unit": unit - }, - } - return quantity_dict - - -def generate_criteria_parser(reserved_expressions): - start_symbol = "START" - end_symbol = "END" - null_symbol = "NULL" - - criteria_operations = { - "matches", - "dimension", - "=", - "<=", - ">=", - "<", - ">", - } - - token_list = [ - TokenPattern(start_symbol, start_symbol), - TokenPattern(end_symbol, end_symbol), - TokenPattern(null_symbol, null_symbol), - TokenPattern(Lexeme(("BOOL",), space_before=True, space_after=True), "BOOL"), - TokenPattern(Lexeme(("QUANTITY",), space_before=True, space_after=True), "QUANTITY"), - TokenPattern(Lexeme(("DIMENSION",), space_before=True, space_after=True), "DIMENSION"), - TokenPattern(Lexeme(("(",), space_after=True), "START_DELIMITER"), - TokenPattern(Lexeme((")",), space_before=True), "END_DELIMITER"), - TokenPattern("response", "QUANTITY"), - TokenPattern("answer", "QUANTITY"), - CatchAll("INPUT", "INPUT"), - ] - token_list += [TokenPattern(Lexeme((x,), space_before=True, space_after=True), " "+x+" ") for x in criteria_operations] - - productions = [ - ("START", "BOOL", create_node), - ("BOOL", "QUANTITY matches QUANTITY", infix), - ("BOOL", "DIMENSION matches DIMENSION", infix), - ("BOOL", "QUANTITY=QUANTITY", infix), - ("BOOL", "QUANTITY<=QUANTITY", infix), - ("BOOL", "QUANTITY>=QUANTITY", infix), - ("BOOL", "QUANTITYQUANTITY", infix), - ("QUANTITY", "INPUT", create_node), - ("DIMENSION", "dimension(QUANTITY)", operate(1)), - ] - - return SLRParser(token_list, productions, start_symbol, end_symbol, null_symbol) - - -def comparison_function(comparison_operator): - none_placeholder = Symbol('NONE_PLACEHOLDER') - comparison_type_dictionary = { - "=": lambda a, b: bool(a == b), - ">": lambda a, b: bool(a > b), - "<": lambda a, b: bool(a < b), - ">=": lambda a, b: bool(a >= b), - "<=": lambda a, b: bool(a <= b), - } - comparison = comparison_type_dictionary[comparison_operator] - - def comparison_function_inner(lhs, rhs, substitutions): - local_substitutions = [(key, none_placeholder) if expr is None else (key, expr) for (key, expr) in substitutions] - expr0 = lhs.subs(local_substitutions) - expr1 = rhs.subs(local_substitutions) - result = comparison((expr0-expr1).cancel().simplify().simplify(), 0) - return result - return comparison_function_inner - - -def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): - graph = CriteriaGraph(label) - END = graph.add_end_node() - reserved_expressions = parameters["reserved_expressions"].items() - parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) - - if label is None: - label = criterion.content_string() - - inputs = criterion.children - lhs_string = criterion.children[0].content_string() - rhs_string = criterion.children[1].content_string() - lhs = parse_expression(lhs_string, parsing_params) - rhs = parse_expression(rhs_string, parsing_params) - - stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] - compare = comparison_function(comparison_operator) - - def compare_evaluate(unused_input): - return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True) - - graph.add_evaluation_node( - label, - summary=f"Is {inputs[0]} {comparison_operator} {inputs[1]}?", - details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", - evaluate=compare_evaluate - ) - attach_terminal_criterion( - graph, - label, - label+"_TRUE", - END, - summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", - details=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", - feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE") - ) - attach_terminal_criterion( - graph, - label, - label+"_FALSE", - END, - summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", - details=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", - feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("FALSE") - ) - # TODO: Consider adding node for cases where comparison cannot be done / cannot be determined - - return graph - - -def greater_than_node(criterion, parameters, label=None): - return comparison_base_graph(criterion, parameters, comparison_operator=">", label=label) - - -def greater_than_or_equal_node(criterion, parameters, label=None): - # TODO: Add nodes for the equal case - graph = comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) - return graph - - -def less_than_node(criterion, parameters, label=None): - return comparison_base_graph(criterion, parameters, comparison_operator="<", label=label) - - -def less_than_or_equal_node(criterion, parameters, label=None): - # TODO: Add nodes for the equal case - graph = comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label) - return graph - - -def criterion_match_node(criterion, parameters, label=None): - graph = CriteriaGraph(label) - END = graph.add_end_node() - reserved_expressions = parameters["reserved_expressions"].items() - parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) - rtol = parameters["parsing_parameters"]["rtol"] - atol = parameters["parsing_parameters"]["atol"] - if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: - ans = parameters["reserved_expressions"]["answer"]["quantity"].value - if ans is not None: - rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) - - if label is None: - label = criterion.content_string() - - inputs = criterion.children - lhs_string = criterion.children[0].content_string() - rhs_string = criterion.children[1].content_string() - lhs = parse_expression(lhs_string, parsing_params) - rhs = parse_expression(rhs_string, parsing_params) - - is_equal = comparison_function("=") - - def is_proportional(lhs, rhs, substitutions): - none_placeholder = Symbol('NONE_PLACEHOLDER') - local_substitutions = [(key, none_placeholder) if expr is None else (key, expr) for (key, expr) in substitutions] - expr0 = lhs.subs(local_substitutions) - expr1 = rhs.subs(local_substitutions) - if expr0.cancel().simplify().simplify() != 0: - result = (expr0/expr1).cancel().simplify().is_constant() - ratio = expr0/expr1 - elif expr0.cancel().simplify().simplify() != 0: - result = (expr1/expr0).cancel().simplify().is_constant() - ratio = expr1/expr0 - else: - result = False - if result is True: - ratio = float(ratio.cancel().simplify()) - else: - ratio = None - return result, ratio - - def quantity_match(unused_inputs): - # TODO: Better system for identifying if we expect the response to have value/unit or not - if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string): - res_value = parameters["reserved_expressions"]["response"]["original"]["value"] - ans_value = parameters["reserved_expressions"]["answer"]["original"]["value"] - res_unit = parameters["reserved_expressions"]["response"]["original"]["unit"] - ans_unit = parameters["reserved_expressions"]["answer"]["original"]["unit"] - if res_value is None and ans_value is not None: - return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} - if res_value is not None and ans_value is None: - return {label+"_UNEXPECTED_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} - if res_unit is None and ans_unit is not None: - return {label+"_MISSING_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} - if res_unit is not None and ans_unit is None: - return {label+"_UNEXPECTED_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} - - substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions] - value_match = is_equal(lhs, rhs, substitutions) - - if value_match is False: - # TODO: better analysis of where `answer` is found in the criteria so that - # numerical tolerances can be applied appropriately - if rtol > 0 or atol > 0: - if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): - ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify() - res = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify() - if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): - if rtol > 0 and (ans != 0): - value_match = bool(abs(float((ans-res)/ans)) < rtol) - elif atol > 0 or (ans == 0): - answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].unit_factor) - value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) - - substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] - unit_match = is_equal(lhs, rhs, substitutions) - - return outcome_tag(label, value_match is True and unit_match is True, {"lhs": lhs_string, "rhs": rhs_string}) - - def dimension_match(unused_inputs): - substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] - dimension_match, _ = is_proportional(lhs, rhs, substitutions) - return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) - - def unit_comparison(unused_inputs): - substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] - - if is_equal(lhs, rhs, substitutions) is True: - output_tags = { - label+"_UNIT_COMPARISON"+"_IDENTICAL": None - } - else: - local_substitutions = [(key, expr["quantity"].expanded_unit) for (key, expr) in reserved_expressions] - result, ratio = is_proportional(lhs, rhs, local_substitutions) - if result is True and ratio >= 1000: - output_tags = { - label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE": None - } - elif result is True and ratio <= 1/1000: - output_tags = { - label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL": None - } - else: - output_tags = { - label+"_UNIT_COMPARISON"+"_SIMILAR": None - } - - return output_tags - - graph.add_evaluation_node( - label, - summary=f"Do the quantities {inputs[0]} and {inputs[1]} match?", - details=f"Converts {inputs[0]} and {inputs[1]} match to a common set of base units and compares their values.", - evaluate=quantity_match - ) - graph.attach_new_criterion( - label, - label+"_TRUE", - summary=f"The quantities {inputs[0]} and {inputs[1]} match.", - details=f"The quantities {inputs[0]} and {inputs[1]} match.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MATCH") - ) - graph.attach_new_criterion( - label, - label+"_FALSE", - summary=f"The quantities {inputs[0]} and {inputs[1]} does not match.", - details=f"The quantities {inputs[0]} and {inputs[1]} does not match.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MISMATCH") - ) - attach_terminal_criterion( - graph, - label, - label+"_MISSING_VALUE", - END, - summary="The response is missing a value.", - details="The response is missing a value.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE") - ) - attach_terminal_criterion( - graph, - label, - label+"_UNEXPECTED_VALUE", - END, - summary="The response is expected only have unit(s), no value.", - details="The response is expected only have unit(s), no value.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE") - ) - attach_terminal_criterion( - graph, - label, - label+"_MISSING_UNIT", - END, - summary="The response is missing unit(s).", - details="The response is missing unit(s).", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT") - ) - attach_terminal_criterion( - graph, - label, - label+"_UNEXPECTED_UNIT", - END, - summary="The response is expected to be a value without unit(s).", - details="The response is expected to be a value without unit(s).", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") - ) - graph.attach_new_evaluation( - label+"_FALSE", - label+"_DIMENSION_MATCH", - summary=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", - details=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", - evaluate=dimension_match - ) - attach_terminal_criterion( - graph, - label+"_DIMENSION_MATCH", - label+"_DIMENSION_MATCH"+"_TRUE", - END, - summary=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", - details=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MATCH") - ) - attach_terminal_criterion( - graph, - label+"_DIMENSION_MATCH", - label+"_DIMENSION_MATCH"+"_FALSE", - END, - summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", - details=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MISMATCH") - ) - graph.attach_new_evaluation( - label+"_TRUE", - label+"_UNIT_COMPARISON", - summary=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", - details=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", - evaluate=unit_comparison - ) - graph.attach_new_criterion( - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_IDENTICAL", - summary=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", - details=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_IDENTICAL") - ) - graph.attach_new_criterion( - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_SIMILAR", - summary=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", - details=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_SIMILAR") - ) - attach_terminal_criterion( - graph, - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", - END, - summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", - details=f"The units of {inputs[0]} are at least 1000 times greater than the units of {inputs[1]}.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE") - ) - attach_terminal_criterion( - graph, - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", - END, - summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", - details=f"The units of {inputs[0]} are at least 1000 times smaller than the units of {inputs[1]}.", - feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_SMALL") - ) - - return graph +graph_templates = { + "matches": criterion_match_node, + ">": greater_than_node, + ">=": greater_than_or_equal_node, + "<": less_than_node, + "<=": less_than_or_equal_node, +} def feedback_procedure_generator(parameters_dict): graphs = dict() for (label, criterion) in parameters_dict["criteria"].items(): - graph_templates = { - "matches": criterion_match_node, - ">": greater_than_node, - ">=": greater_than_or_equal_node, - "<": less_than_node, - "<=": less_than_or_equal_node, - } graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) graph = graph_template(criterion, parameters_dict) disable_nodes(graph, parameters_dict.get("disabled_evaluation_nodes", set())) diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py new file mode 100644 index 0000000..6d639cb --- /dev/null +++ b/app/context/physical_quantity/comparison.py @@ -0,0 +1,42 @@ +from sympy import Symbol + + +def _substitute(lhs, rhs, substitutions): + """`lhs` and `rhs` with `substitutions` applied, where substitutions by None use a placeholder symbol instead.""" + none_placeholder = Symbol('NONE_PLACEHOLDER') + local_substitutions = [(key, none_placeholder) if expr is None else (key, expr) for (key, expr) in substitutions] + return lhs.subs(local_substitutions), rhs.subs(local_substitutions) + + +def comparison_function(comparison_operator): + comparison_type_dictionary = { + "=": lambda a, b: bool(a == b), + ">": lambda a, b: bool(a > b), + "<": lambda a, b: bool(a < b), + ">=": lambda a, b: bool(a >= b), + "<=": lambda a, b: bool(a <= b), + } + comparison = comparison_type_dictionary[comparison_operator] + + def comparison_function_inner(lhs, rhs, substitutions): + expr0, expr1 = _substitute(lhs, rhs, substitutions) + result = comparison((expr0-expr1).cancel().simplify().simplify(), 0) + return result + return comparison_function_inner + + +def is_proportional(lhs, rhs, substitutions): + expr0, expr1 = _substitute(lhs, rhs, substitutions) + if expr0.cancel().simplify().simplify() != 0: + result = (expr0/expr1).cancel().simplify().is_constant() + ratio = expr0/expr1 + elif expr0.cancel().simplify().simplify() != 0: + result = (expr1/expr0).cancel().simplify().is_constant() + ratio = expr1/expr0 + else: + result = False + if result is True: + ratio = float(ratio.cancel().simplify()) + else: + ratio = None + return result, ratio diff --git a/app/context/physical_quantity/criteria_parser.py b/app/context/physical_quantity/criteria_parser.py new file mode 100644 index 0000000..f30720a --- /dev/null +++ b/app/context/physical_quantity/criteria_parser.py @@ -0,0 +1,47 @@ +from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate + + +def generate_criteria_parser(reserved_expressions): + start_symbol = "START" + end_symbol = "END" + null_symbol = "NULL" + + criteria_operations = { + "matches", + "dimension", + "=", + "<=", + ">=", + "<", + ">", + } + + token_list = [ + TokenPattern(start_symbol, start_symbol), + TokenPattern(end_symbol, end_symbol), + TokenPattern(null_symbol, null_symbol), + TokenPattern(Lexeme(("BOOL",), space_before=True, space_after=True), "BOOL"), + TokenPattern(Lexeme(("QUANTITY",), space_before=True, space_after=True), "QUANTITY"), + TokenPattern(Lexeme(("DIMENSION",), space_before=True, space_after=True), "DIMENSION"), + TokenPattern(Lexeme(("(",), space_after=True), "START_DELIMITER"), + TokenPattern(Lexeme((")",), space_before=True), "END_DELIMITER"), + TokenPattern("response", "QUANTITY"), + TokenPattern("answer", "QUANTITY"), + CatchAll("INPUT", "INPUT"), + ] + token_list += [TokenPattern(Lexeme((x,), space_before=True, space_after=True), " "+x+" ") for x in criteria_operations] + + productions = [ + ("START", "BOOL", create_node), + ("BOOL", "QUANTITY matches QUANTITY", infix), + ("BOOL", "DIMENSION matches DIMENSION", infix), + ("BOOL", "QUANTITY=QUANTITY", infix), + ("BOOL", "QUANTITY<=QUANTITY", infix), + ("BOOL", "QUANTITY>=QUANTITY", infix), + ("BOOL", "QUANTITYQUANTITY", infix), + ("QUANTITY", "INPUT", create_node), + ("DIMENSION", "dimension(QUANTITY)", operate(1)), + ] + + return SLRParser(token_list, productions, start_symbol, end_symbol, null_symbol) diff --git a/app/context/physical_quantity/graphs/__init__.py b/app/context/physical_quantity/graphs/__init__.py new file mode 100644 index 0000000..72d9b5a --- /dev/null +++ b/app/context/physical_quantity/graphs/__init__.py @@ -0,0 +1 @@ +"""Builders for the criteria graphs of the physical quantity context, one module per kind of criterion.""" diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py new file mode 100644 index 0000000..098ed28 --- /dev/null +++ b/app/context/physical_quantity/graphs/match.py @@ -0,0 +1,266 @@ +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import compute_relative_tolerance_from_significant_decimals, parse_expression + +from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators +from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ..comparison import comparison_function, is_proportional + +is_equal = comparison_function("=") + + +def _tolerances(parameters): + """`(rtol, atol)`, with `rtol` taken from the significant figures of the answer if no tolerance is given.""" + rtol = parameters["parsing_parameters"]["rtol"] + atol = parameters["parsing_parameters"]["atol"] + if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: + ans = parameters["reserved_expressions"]["answer"]["quantity"].value + if ans is not None: + rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) + return rtol, atol + + +def _missing_or_unexpected(parameters, label, lhs_string, rhs_string): + """Tag for a response missing a value or unit that the answer has (or vice versa), otherwise None.""" + # TODO: Better system for identifying if we expect the response to have value/unit or not + if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string): + res_value = parameters["reserved_expressions"]["response"]["original"]["value"] + ans_value = parameters["reserved_expressions"]["answer"]["original"]["value"] + res_unit = parameters["reserved_expressions"]["response"]["original"]["unit"] + ans_unit = parameters["reserved_expressions"]["answer"]["original"]["unit"] + if res_value is None and ans_value is not None: + return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} + if res_value is not None and ans_value is None: + return {label+"_UNEXPECTED_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} + if res_unit is None and ans_unit is not None: + return {label+"_MISSING_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} + if res_unit is not None and ans_unit is None: + return {label+"_UNEXPECTED_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} + return None + + +def _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol): + """Whether the standard values of the response and answer are within the numerical tolerances.""" + value_match = False + # TODO: better analysis of where `answer` is found in the criteria so that + # numerical tolerances can be applied appropriately + if rtol > 0 or atol > 0: + if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): + ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify() + res = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify() + if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): + if rtol > 0 and (ans != 0): + value_match = bool(abs(float((ans-res)/ans)) < rtol) + elif atol > 0 or (ans == 0): + answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].unit_factor) + value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) + return value_match + + +def _quantity_match(parameters, label, lhs, rhs, lhs_string, rhs_string, rtol, atol): + reserved_expressions = parameters["reserved_expressions"].items() + + def quantity_match(unused_inputs): + missing_or_unexpected = _missing_or_unexpected(parameters, label, lhs_string, rhs_string) + if missing_or_unexpected is not None: + return missing_or_unexpected + + substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions] + value_match = is_equal(lhs, rhs, substitutions) + if value_match is False: + value_match = _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol) + + substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] + unit_match = is_equal(lhs, rhs, substitutions) + + return outcome_tag(label, value_match is True and unit_match is True, {"lhs": lhs_string, "rhs": rhs_string}) + return quantity_match + + +def _dimension_match(parameters, label, lhs, rhs, lhs_string, rhs_string): + reserved_expressions = parameters["reserved_expressions"].items() + + def dimension_match(unused_inputs): + substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] + dimension_match, _ = is_proportional(lhs, rhs, substitutions) + return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) + return dimension_match + + +def _unit_comparison(parameters, label, lhs, rhs): + reserved_expressions = parameters["reserved_expressions"].items() + + def unit_comparison(unused_inputs): + substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] + + if is_equal(lhs, rhs, substitutions) is True: + output_tags = { + label+"_UNIT_COMPARISON"+"_IDENTICAL": None + } + else: + local_substitutions = [(key, expr["quantity"].expanded_unit) for (key, expr) in reserved_expressions] + result, ratio = is_proportional(lhs, rhs, local_substitutions) + if result is True and ratio >= 1000: + output_tags = { + label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE": None + } + elif result is True and ratio <= 1/1000: + output_tags = { + label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL": None + } + else: + output_tags = { + label+"_UNIT_COMPARISON"+"_SIMILAR": None + } + + return output_tags + return unit_comparison + + +def _add_match_nodes(graph, END, label, inputs, quantity_match, dimension_match, unit_comparison): + graph.add_evaluation_node( + label, + summary=f"Do the quantities {inputs[0]} and {inputs[1]} match?", + details=f"Converts {inputs[0]} and {inputs[1]} match to a common set of base units and compares their values.", + evaluate=quantity_match + ) + graph.attach_new_criterion( + label, + label+"_TRUE", + summary=f"The quantities {inputs[0]} and {inputs[1]} match.", + details=f"The quantities {inputs[0]} and {inputs[1]} match.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MATCH") + ) + graph.attach_new_criterion( + label, + label+"_FALSE", + summary=f"The quantities {inputs[0]} and {inputs[1]} does not match.", + details=f"The quantities {inputs[0]} and {inputs[1]} does not match.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MISMATCH") + ) + attach_terminal_criterion( + graph, + label, + label+"_MISSING_VALUE", + END, + summary="The response is missing a value.", + details="The response is missing a value.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE") + ) + attach_terminal_criterion( + graph, + label, + label+"_UNEXPECTED_VALUE", + END, + summary="The response is expected only have unit(s), no value.", + details="The response is expected only have unit(s), no value.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE") + ) + attach_terminal_criterion( + graph, + label, + label+"_MISSING_UNIT", + END, + summary="The response is missing unit(s).", + details="The response is missing unit(s).", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT") + ) + attach_terminal_criterion( + graph, + label, + label+"_UNEXPECTED_UNIT", + END, + summary="The response is expected to be a value without unit(s).", + details="The response is expected to be a value without unit(s).", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") + ) + graph.attach_new_evaluation( + label+"_FALSE", + label+"_DIMENSION_MATCH", + summary=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", + details=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", + evaluate=dimension_match + ) + attach_terminal_criterion( + graph, + label+"_DIMENSION_MATCH", + label+"_DIMENSION_MATCH"+"_TRUE", + END, + summary=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", + details=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MATCH") + ) + attach_terminal_criterion( + graph, + label+"_DIMENSION_MATCH", + label+"_DIMENSION_MATCH"+"_FALSE", + END, + summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", + details=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MISMATCH") + ) + graph.attach_new_evaluation( + label+"_TRUE", + label+"_UNIT_COMPARISON", + summary=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", + details=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", + evaluate=unit_comparison + ) + graph.attach_new_criterion( + label+"_UNIT_COMPARISON", + label+"_UNIT_COMPARISON"+"_IDENTICAL", + summary=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", + details=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_IDENTICAL") + ) + graph.attach_new_criterion( + label+"_UNIT_COMPARISON", + label+"_UNIT_COMPARISON"+"_SIMILAR", + summary=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", + details=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_SIMILAR") + ) + attach_terminal_criterion( + graph, + label+"_UNIT_COMPARISON", + label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", + END, + summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", + details=f"The units of {inputs[0]} are at least 1000 times greater than the units of {inputs[1]}.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE") + ) + attach_terminal_criterion( + graph, + label+"_UNIT_COMPARISON", + label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", + END, + summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", + details=f"The units of {inputs[0]} are at least 1000 times smaller than the units of {inputs[1]}.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_SMALL") + ) + + +def criterion_match_node(criterion, parameters, label=None): + graph = CriteriaGraph(label) + END = graph.add_end_node() + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) + rtol, atol = _tolerances(parameters) + + if label is None: + label = criterion.content_string() + + inputs = criterion.children + lhs_string = criterion.children[0].content_string() + rhs_string = criterion.children[1].content_string() + lhs = parse_expression(lhs_string, parsing_params) + rhs = parse_expression(rhs_string, parsing_params) + + _add_match_nodes( + graph, + END, + label, + inputs, + quantity_match=_quantity_match(parameters, label, lhs, rhs, lhs_string, rhs_string, rtol, atol), + dimension_match=_dimension_match(parameters, label, lhs, rhs, lhs_string, rhs_string), + unit_comparison=_unit_comparison(parameters, label, lhs, rhs), + ) + return graph diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py new file mode 100644 index 0000000..c27e411 --- /dev/null +++ b/app/context/physical_quantity/graphs/order.py @@ -0,0 +1,76 @@ +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import parse_expression + +from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators +from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ..comparison import comparison_function + + +def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): + graph = CriteriaGraph(label) + END = graph.add_end_node() + reserved_expressions = parameters["reserved_expressions"].items() + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) + + if label is None: + label = criterion.content_string() + + inputs = criterion.children + lhs_string = criterion.children[0].content_string() + rhs_string = criterion.children[1].content_string() + lhs = parse_expression(lhs_string, parsing_params) + rhs = parse_expression(rhs_string, parsing_params) + + stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] + compare = comparison_function(comparison_operator) + + def compare_evaluate(unused_input): + return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True) + + graph.add_evaluation_node( + label, + summary=f"Is {inputs[0]} {comparison_operator} {inputs[1]}?", + details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", + evaluate=compare_evaluate + ) + attach_terminal_criterion( + graph, + label, + label+"_TRUE", + END, + summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", + details=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", + feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE") + ) + attach_terminal_criterion( + graph, + label, + label+"_FALSE", + END, + summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", + details=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", + feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("FALSE") + ) + # TODO: Consider adding node for cases where comparison cannot be done / cannot be determined + + return graph + + +def greater_than_node(criterion, parameters, label=None): + return comparison_base_graph(criterion, parameters, comparison_operator=">", label=label) + + +def greater_than_or_equal_node(criterion, parameters, label=None): + # TODO: Add nodes for the equal case + graph = comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) + return graph + + +def less_than_node(criterion, parameters, label=None): + return comparison_base_graph(criterion, parameters, comparison_operator="<", label=label) + + +def less_than_or_equal_node(criterion, parameters, label=None): + # TODO: Add nodes for the equal case + graph = comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label) + return graph diff --git a/app/context/physical_quantity/parsing.py b/app/context/physical_quantity/parsing.py new file mode 100644 index 0000000..473af11 --- /dev/null +++ b/app/context/physical_quantity/parsing.py @@ -0,0 +1,31 @@ +from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.units import parse_quantity as parse_physical_quantity + +from ...feedback.physical_quantity import reverted_unit_feedback + + +def parse_quantity(name, expr, parameters, evaluation_result): + quantity = parse_physical_quantity(expr, parameters["quantity"], name) + for index, reverted in enumerate(quantity.reverted_units): + evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) + if quantity.standard_values is None: + standard_value = None + elif len(quantity.standard_values) == 1: + standard_value = quantity.standard_values[0] + else: + standard_value = set(quantity.standard_values) + value = parse_expression(quantity.value.content_string(), quantity.parsing_config) if quantity.value is not None else None + unit = parse_expression(quantity.unit.content_string(), quantity.parsing_config) if quantity.unit is not None else None + quantity_dict = { + "quantity": quantity, + "standard": { + "value": standard_value, + "unit": quantity.standard_unit + }, + "dimension": quantity.dimension, + "original": { + "value": value, + "unit": unit + }, + } + return quantity_dict From e3fdaf52b5f45dd855be54cc3722e4572b3a9b41 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 13:11:48 +0100 Subject: [PATCH 08/25] Refactor evaluation module: extract reusable logic into helper functions (`_input_symbol_codes_and_aliases`, `_raise_on_collisions`, `_build_parameters`, etc.), remove redundant in-line implementations, and streamline `FrozenValuesDictionary` usage. --- app/evaluation.py | 198 ++++++++++++++++++++++++--------------------- app/frozen_dict.py | 25 ++++++ 2 files changed, 130 insertions(+), 93 deletions(-) create mode 100644 app/frozen_dict.py diff --git a/app/evaluation.py b/app/evaluation.py index 8b7f4f4..4825a09 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -13,8 +13,7 @@ from .feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from .feedback.symbolic import internal_feedback from .feedback.tags import FeedbackTag, FeedbackTagName - -from collections.abc import Mapping +from .frozen_dict import FrozenValuesDictionary messages = { "RESERVED_EXPRESSION_MISSING": lambda label: f"Reserved expression `{label}` is not defined." @@ -28,28 +27,30 @@ def notation_error_feedback(error, original_text): return FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": error.name}) -class FrozenValuesDictionary(dict): - """ - A dictionary where new key:value pairs can be added, - but changing the value for an existing key raises - a TypeError - """ - def __init__(self, other=None, **kwargs): - super().__init__() - self.update(other, **kwargs) +def _input_symbol_codes_and_aliases(parameters): + """The codes and (non-empty) aliases of the input symbols, from both `symbols` and the legacy `input_symbols`.""" + codes = list(parameters.get("symbols", dict())) + aliases = [] - def __setitem__(self, key, value): - if key in self: - msg = 'key {!r} already exists with value {!r}' - raise TypeError(msg.format(key, self[key])) - super().__setitem__(key, value) + for input_symbol in parameters.get("symbols", dict()).values(): + aliases += [alias for alias in input_symbol.get("aliases", []) if len(alias.strip()) > 0] + + # This code is to ensure compatibility with legacy system for defining input symbols + for input_symbol in parameters.get("input_symbols", []): + if len(input_symbol[0].strip()) > 0: + codes.append(input_symbol[0]) + aliases += [ip for ip in input_symbol[1] if len(ip.strip()) > 0] + + return codes, aliases - def update(self, other=None, **kwargs): - if other is not None: - for k, v in other.items() if isinstance(other, Mapping) else other: - self[k] = v - for k, v in kwargs.items(): - self[k] = v + +def _raise_on_collisions(keywords, symbols, kind): + """Raises if any of `keywords` is used as an input symbol `kind` (e.g. "code" or "alternative").""" + collisions = [keyword for keyword in keywords if keyword in symbols] + if len(collisions) == 1: + raise Exception("`"+"`, `".join(collisions)+f"` is a reserved keyword and cannot be used as an input symbol {kind}.") + elif len(collisions) > 1: + raise Exception("`"+"`, `".join(collisions)+f"` are reserved keywords and cannot be used as input symbol {kind}s.") def determine_context(parameters): @@ -58,41 +59,15 @@ def determine_context(parameters): else: context = deepcopy(symbolic_context) - input_symbols_reserved_codes = list(parameters.get("symbols", dict())) - input_symbols_reserved_aliases = [] - - for input_symbol in parameters.get("symbols", dict()).values(): - input_symbols_reserved_aliases += [alias for alias in input_symbol.get("aliases", []) if len(alias.strip()) > 0] - - # This code is to ensure compatibility with legacy system for defining input symbols - for input_symbol in parameters.get("input_symbols", []): - if len(input_symbol[0].strip()) > 0: - input_symbols_reserved_codes.append(input_symbol[0]) - input_symbols_reserved_aliases += [ip for ip in input_symbol[1] if len(ip.strip()) > 0] + input_symbols_reserved_codes, input_symbols_reserved_aliases = _input_symbol_codes_and_aliases(parameters) reserved_keywords_codes = {"where", "written as", "contains", "proportional to"} reserved_keywords_aliases = {"plus_minus", "minus_plus"} for expressions in parameters["reserved_expressions_strings"].values(): reserved_keywords_aliases = reserved_keywords_aliases.union(set(expressions.keys())) - reserved_keywords_codes_collisions = [] - for keyword in reserved_keywords_codes: - if keyword in input_symbols_reserved_codes: - reserved_keywords_codes_collisions.append(keyword) - if len(reserved_keywords_codes_collisions) > 0: - if len(reserved_keywords_codes_collisions) == 1: - raise Exception("`"+"`, `".join(reserved_keywords_codes_collisions)+"` is a reserved keyword and cannot be used as an input symbol code.") - else: - raise Exception("`"+"`, `".join(reserved_keywords_codes_collisions)+"` are reserved keywords and cannot be used as input symbol codes.") - reserved_keywords_aliases_collisions = [] - for keyword in reserved_keywords_aliases: - if keyword in input_symbols_reserved_aliases: - reserved_keywords_aliases_collisions.append(keyword) - if len(reserved_keywords_aliases_collisions) > 0: - if len(reserved_keywords_aliases_collisions) == 1: - raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` is a reserved keyword and cannot be used as an input symbol alternative.") - else: - raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` are reserved keywords and cannot be used as input symbol alternatives.") + _raise_on_collisions(reserved_keywords_codes, input_symbols_reserved_codes, "code") + _raise_on_collisions(reserved_keywords_aliases, input_symbols_reserved_aliases, "alternative") context.update({"reserved_keywords": list(reserved_keywords_codes)}) return context @@ -173,14 +148,8 @@ def get_criteria_string(parameters): return criteria -def create_criteria_dict(criteria_parser, parsing_params): - preprocess = parsing_params["context"]["expression_preprocess"] - original_criteria_string = get_criteria_string(parsing_params) - try: - criteria_string = preprocess("criteria", original_criteria_string, parsing_params) - except (BracketNotationError, AbsoluteValueNotationError) as e: - feedback = notation_error_feedback(e, original_criteria_string) - raise Exception(internal_feedback(feedback), str(feedback.tag)) from e +def split_criteria(criteria_string): + """Splits `criteria_string` on the commas that are not inside brackets, and strips each criterion.""" criteria_string_list = [] delims = [ ("(", ")"), @@ -200,8 +169,19 @@ def create_criteria_dict(criteria_parser, parsing_params): criteria_string_list.append(criteria_string[criterion_start:n].strip()) criterion_start = n+1 criteria_string_list.append(criteria_string[criterion_start:].strip()) + return criteria_string_list + + +def create_criteria_dict(criteria_parser, parsing_params): + preprocess = parsing_params["context"]["expression_preprocess"] + original_criteria_string = get_criteria_string(parsing_params) + try: + criteria_string = preprocess("criteria", original_criteria_string, parsing_params) + except (BracketNotationError, AbsoluteValueNotationError) as e: + feedback = notation_error_feedback(e, original_criteria_string) + raise Exception(internal_feedback(feedback), str(feedback.tag)) from e criteria_parsed = FrozenValuesDictionary() - for criterion in criteria_string_list: + for criterion in split_criteria(criteria_string): try: criterion_tokens = criteria_parser.scan(criterion) criterion_parsed = criteria_parser.parse(criterion_tokens)[0] @@ -211,11 +191,10 @@ def create_criteria_dict(criteria_parser, parsing_params): return criteria_parsed -def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): +def generate_feedback(criteria_graphs, evaluation_parameters): # Generate feedback from criteria graphs evaluation_result = evaluation_parameters["evaluation_result"] response = evaluation_parameters["reserved_expressions"]["response"] - criteria_feedback = set() is_correct = True custom_feedback = evaluation_parameters.get("custom_feedback", {}) for (criterion_identifier, graph) in criteria_graphs.items(): @@ -237,25 +216,20 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): return -def evaluation_function(response, answer, params, include_test_data=False) -> dict: - """ - Function that allows for various types of comparison of various kinds of expressions. - Supported input parameters: - strict_SI_syntax: - - if set to True, use basic dimensional analysis functionality. - """ - +def _apply_tolerance_aliases(params): + """Copies `relative_tolerance`/`absolute_tolerance` to `rtol`/`atol` in `params`.""" if "relative_tolerance" in params: params["rtol"] = params["relative_tolerance"] if "absolute_tolerance" in params: params["atol"] = params["absolute_tolerance"] - evaluation_result = EvaluationResult() - evaluation_result.is_correct = False - - symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] +def _build_parameters(params, response, answer): + """ + A copy of `params` completed with the context's defaults, the reserved expressions + (with `response` read as LaTeX if `is_latex` is set), the context and the parsing parameters. + """ parameters = deepcopy(params) reserved_expressions_strings = { @@ -298,25 +272,33 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di "response": parse_latex(response, ExpressionParams.from_dict(parameters).symbols), } ) + return parameters + +def notation_warnings(response, strict_syntax): + """The tags of the notation warnings for `response`.""" # FIXME: Move these notation warnings into lambdafeedback.expression_parsing - if params.get("strict_syntax", False): + tags = [] + if strict_syntax: if "^" in response: - evaluation_result.add_feedback("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict())) + tags.append("NOTATION_WARNING_EXPONENT") if "!" in response: - evaluation_result.add_feedback("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict())) - + tags.append("NOTATION_WARNING_FACTORIAL") if "!!!" in response: - evaluation_result.add_feedback( - "NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict())) + tags.append("NOTATION_WARNING_TRIPLE_FACTORIAL") + return tags - 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) - reserved_expressions_parsed = {**reserved_expressions["learner"], **reserved_expressions["task"]} +def _add_notation_warnings(evaluation_result, response, params): + symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] + for tag in notation_warnings(response, params.get("strict_syntax", False)): + evaluation_result.add_feedback(tag, symbolic_comparison_internal_messages(tag)(dict())) + + +def _set_preview_fields(evaluation_result, response, parameters, reserved_expressions): + """Sets the `latex` and `simplified` forms of the response on `evaluation_result`.""" try: - preview = context["expression_preview"](response, deepcopy(parameters))["preview"] + preview = parameters["context"]["expression_preview"](response, deepcopy(parameters))["preview"] except Exception: evaluation_result.latex = response evaluation_result.simplified = response @@ -331,16 +313,15 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di except Exception: evaluation_result.simplified = response - criteria_parser = context["generate_criteria_parser"](reserved_expressions) - criteria = create_criteria_dict(criteria_parser, parameters) - evaluation_parameters = FrozenValuesDictionary( +def _build_evaluation_parameters(parameters, reserved_expressions, criteria, evaluation_result): + return FrozenValuesDictionary( { - "reserved_expressions_strings": reserved_expressions_strings, - "reserved_expressions": reserved_expressions_parsed, + "reserved_expressions_strings": parameters["reserved_expressions_strings"], + "reserved_expressions": {**reserved_expressions["learner"], **reserved_expressions["task"]}, "criteria": criteria, "disabled_evaluation_nodes": parameters.get("disabled_evaluation_nodes", set()), - "parsing_parameters": parsing_parameters, + "parsing_parameters": parameters["parsing_parameters"], "evaluation_result": evaluation_result, "syntactical_comparison": parameters.get("syntactical_comparison", False), "multiple_answers_criteria": parameters.get("multiple_answers_criteria", "all"), @@ -351,9 +332,40 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di } ) + +def evaluation_function(response, answer, params, include_test_data=False) -> dict: + """ + Function that allows for various types of comparison of various kinds of expressions. + Supported input parameters: + strict_SI_syntax: + - if set to True, use basic dimensional analysis functionality. + """ + _apply_tolerance_aliases(params) + + evaluation_result = EvaluationResult() + evaluation_result.is_correct = False + + parameters = _build_parameters(params, response, answer) + context = parameters["context"] + + _add_notation_warnings(evaluation_result, response, params) + + reserved_expressions_success, reserved_expressions = parse_reserved_expressions( + parameters["reserved_expressions_strings"], parameters, evaluation_result + ) + if reserved_expressions_success is False: + return evaluation_result.serialise(include_test_data) + + _set_preview_fields(evaluation_result, response, parameters, reserved_expressions) + + criteria_parser = context["generate_criteria_parser"](reserved_expressions) + criteria = create_criteria_dict(criteria_parser, parameters) + + evaluation_parameters = _build_evaluation_parameters(parameters, reserved_expressions, criteria, evaluation_result) + # Performs evaluation of response - feedback_procedures = parameters["context"]["feedback_procedure_generator"](evaluation_parameters) - generate_feedback(criteria, feedback_procedures, evaluation_parameters) + feedback_procedures = context["feedback_procedure_generator"](evaluation_parameters) + generate_feedback(feedback_procedures, evaluation_parameters) result = evaluation_result.serialise(include_test_data) diff --git a/app/frozen_dict.py b/app/frozen_dict.py new file mode 100644 index 0000000..d31c5d8 --- /dev/null +++ b/app/frozen_dict.py @@ -0,0 +1,25 @@ +from collections.abc import Mapping + + +class FrozenValuesDictionary(dict): + """ + A dictionary where new key:value pairs can be added, + but changing the value for an existing key raises + a TypeError + """ + def __init__(self, other=None, **kwargs): + super().__init__() + self.update(other, **kwargs) + + def __setitem__(self, key, value): + if key in self: + msg = 'key {!r} already exists with value {!r}' + raise TypeError(msg.format(key, self[key])) + super().__setitem__(key, value) + + def update(self, other=None, **kwargs): + if other is not None: + for k, v in other.items() if isinstance(other, Mapping) else other: + self[k] = v + for k, v in kwargs.items(): + self[k] = v From f5d7f527d8cbf6695ae67a9de296d9c269855564 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 13:41:36 +0100 Subject: [PATCH 09/25] Add reserved keyword validations to evaluation tests and refactor feedback logic with `outcome_tag` --- .../symbolic/graphs/syntactical_form.py | 13 ++------ app/evaluation.py | 2 +- app/tests/evaluation_test.py | 33 +++++++++++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index aaa8d89..d892868 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -3,6 +3,7 @@ from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ....feedback.syntactical_forms import PATTERNS as syntactical_forms +from ...graph_helpers import outcome_tag def written_as_known_form(string, name): @@ -20,22 +21,14 @@ def find_form(unused_input): def compare_to_form(string, form_label, label): def inner(unused_input): - result = dict() - if syntactical_forms[form_label].matcher(string) is True: - result.update({label+"_TRUE": None}) - else: - result.update({label+"_FALSE": None}) - return result + return outcome_tag(label, syntactical_forms[form_label].matcher(string) is True) return inner def response_written_as_answer(response, answer, label): def inner(unused_input): matcher = generate_arbitrary_number_pattern_matcher(answer) - if matcher(response): - return {label+"_TRUE": None} - else: - return {label+"_FALSE": None} + return outcome_tag(label, bool(matcher(response))) return inner diff --git a/app/evaluation.py b/app/evaluation.py index 4825a09..fe2e61b 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -46,7 +46,7 @@ def _input_symbol_codes_and_aliases(parameters): def _raise_on_collisions(keywords, symbols, kind): """Raises if any of `keywords` is used as an input symbol `kind` (e.g. "code" or "alternative").""" - collisions = [keyword for keyword in keywords if keyword in symbols] + collisions = sorted(keyword for keyword in keywords if keyword in symbols) if len(collisions) == 1: raise Exception("`"+"`, `".join(collisions)+f"` is a reserved keyword and cannot be used as an input symbol {kind}.") elif len(collisions) > 1: diff --git a/app/tests/evaluation_test.py b/app/tests/evaluation_test.py index c44f505..589a138 100755 --- a/app/tests/evaluation_test.py +++ b/app/tests/evaluation_test.py @@ -243,3 +243,36 @@ def test_laplace_transforms(self, response, is_latex, is_correct): result = evaluation_function(response, answer, params) assert result["is_correct"] == is_correct + + @pytest.mark.parametrize( + "params, message", [ + ( + {"symbols": {"where": {"aliases": []}}}, + "`where` is a reserved keyword and cannot be used as an input symbol code." + ), + ( + {"input_symbols": [["written as", []]]}, + "`written as` is a reserved keyword and cannot be used as an input symbol code." + ), + ( + {"symbols": {"x": {"aliases": ["response"]}}}, + "`response` is a reserved keyword and cannot be used as an input symbol alternative." + ), + ( + {"input_symbols": [["x", ["plus_minus"]]]}, + "`plus_minus` is a reserved keyword and cannot be used as an input symbol alternative." + ), + ( + {"symbols": {"where": {"aliases": []}, "contains": {"aliases": []}}}, + "`contains`, `where` are reserved keywords and cannot be used as input symbol codes." + ), + ( + {"symbols": {"x": {"aliases": ["response", "answer"]}}}, + "`answer`, `response` are reserved keywords and cannot be used as input symbol alternatives." + ), + ] + ) + def test_reserved_keyword_as_input_symbol(self, params, message): + with pytest.raises(Exception) as error: + evaluation_function("x", "x", params) + assert str(error.value) == message From 3ea7e0b1d11ac51594acc0ee3d285240f5c342cb Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 15:36:11 +0100 Subject: [PATCH 10/25] Refactor context structure and evaluation logic: introduce `context/defaults.py` for shared default parameters, extract reusable parsing/comparison utilities (`parse_criterion_sides`, `COMPARISON_OPERATORS`) into dedicated modules, streamline evaluation graph imports and `disable_nodes` logic. --- app/context/defaults.py | 9 ++++ app/context/graph_helpers.py | 9 ---- app/context/physical_quantity/__init__.py | 12 ++--- app/context/physical_quantity/comparison.py | 47 ++++++++++++------- app/context/physical_quantity/graphs/match.py | 10 ++-- app/context/physical_quantity/graphs/order.py | 15 ++---- app/context/symbolic/__init__.py | 16 ++----- app/context/symbolic/comparison.py | 45 +++++++++++------- app/context/symbolic/graphs/equivalence.py | 12 ++--- app/context/symbolic/graphs/generic.py | 8 ++-- .../symbolic/graphs/set_equivalence.py | 14 ++---- app/context/symbolic/graphs/substitution.py | 45 ++++++++++-------- app/docs/dev.md | 2 +- app/evaluation.py | 10 ++++ 14 files changed, 133 insertions(+), 121 deletions(-) create mode 100644 app/context/defaults.py diff --git a/app/context/defaults.py b/app/context/defaults.py new file mode 100644 index 0000000..1d9676b --- /dev/null +++ b/app/context/defaults.py @@ -0,0 +1,9 @@ +"""Default evaluation parameters shared by all contexts; each context adds its own on top of these.""" + +base_default_parameters = { + "complexNumbers": False, + "convention": "equal_precedence", + "elementary_functions": False, + "strict_syntax": False, + "multiple_answers_criteria": "all", +} diff --git a/app/context/graph_helpers.py b/app/context/graph_helpers.py index 8765751..b570a87 100644 --- a/app/context/graph_helpers.py +++ b/app/context/graph_helpers.py @@ -1,7 +1,5 @@ """Helpers for building the `CriteriaGraph`s shared by the symbolic and physical quantity contexts.""" -from lambdafeedback.criteria import CriteriaGraph - def outcome_tag(label, result, inputs=None): """`{label+"_TRUE": inputs}` if `result` is True, `label+"_FALSE"` if it is False, otherwise `label+"_UNKNOWN"`.""" @@ -23,10 +21,3 @@ def attach_terminal_criterion(graph, evaluation_label, label, end, summary, deta feedback_string_generator=feedback_string_generator ) graph.attach(label, end.label) - - -def disable_nodes(graph, disabled_evaluation_nodes): - """Makes each evaluation in `disabled_evaluation_nodes` skip straight to the end of `graph`.""" - for evaluation in graph.evaluations.values(): - if evaluation.label in disabled_evaluation_nodes: - evaluation.replacement = CriteriaGraph.END_LABEL diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index 468b909..948c636 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -4,8 +4,7 @@ from lambdafeedback.units import QuantityParams, preprocess_quantity from ...preview.physical_quantity import preview_function -from ..symbolic import default_parameters as symbolic_default_parameters -from ..graph_helpers import disable_nodes +from ..defaults import base_default_parameters from .criteria_parser import generate_criteria_parser from .graphs.match import criterion_match_node from .graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node @@ -29,9 +28,7 @@ def feedback_procedure_generator(parameters_dict): graphs = dict() for (label, criterion) in parameters_dict["criteria"].items(): graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) - graph = graph_template(criterion, parameters_dict) - disable_nodes(graph, parameters_dict.get("disabled_evaluation_nodes", set())) - graphs.update({label: graph}) + graphs.update({label: graph_template(criterion, parameters_dict)}) return graphs @@ -59,9 +56,8 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as } -# CONSIDER: Move these to separate file so that they can be shared with -# the preview function implementation (or move preview implementation here) -default_parameters = deepcopy(symbolic_default_parameters) +# CONSIDER: Share these with the preview function implementation (or move preview implementation here) +default_parameters = deepcopy(base_default_parameters) default_parameters.update( { "physical_quantity": True, diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index 6d639cb..54db004 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -1,42 +1,55 @@ +import operator + from sympy import Symbol +from lambdafeedback.expression_parsing import parse_expression + +NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') + +COMPARISON_OPERATORS = { + "=": operator.eq, + ">": operator.gt, + "<": operator.lt, + ">=": operator.ge, + "<=": operator.le, +} + + +def parse_criterion_sides(criterion, parameters): + """The left- and right-hand sides of `criterion` as strings, and parsed without simplification.""" + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) + lhs_string = criterion.children[0].content_string() + rhs_string = criterion.children[1].content_string() + return lhs_string, rhs_string, parse_expression(lhs_string, parsing_params), parse_expression(rhs_string, parsing_params) + def _substitute(lhs, rhs, substitutions): """`lhs` and `rhs` with `substitutions` applied, where substitutions by None use a placeholder symbol instead.""" - none_placeholder = Symbol('NONE_PLACEHOLDER') - local_substitutions = [(key, none_placeholder) if expr is None else (key, expr) for (key, expr) in substitutions] + local_substitutions = [(key, NONE_PLACEHOLDER) if expr is None else (key, expr) for (key, expr) in substitutions] return lhs.subs(local_substitutions), rhs.subs(local_substitutions) def comparison_function(comparison_operator): - comparison_type_dictionary = { - "=": lambda a, b: bool(a == b), - ">": lambda a, b: bool(a > b), - "<": lambda a, b: bool(a < b), - ">=": lambda a, b: bool(a >= b), - "<=": lambda a, b: bool(a <= b), - } - comparison = comparison_type_dictionary[comparison_operator] + comparison = COMPARISON_OPERATORS[comparison_operator] def comparison_function_inner(lhs, rhs, substitutions): expr0, expr1 = _substitute(lhs, rhs, substitutions) - result = comparison((expr0-expr1).cancel().simplify().simplify(), 0) - return result + return bool(comparison((expr0-expr1).cancel().simplify().simplify(), 0)) return comparison_function_inner def is_proportional(lhs, rhs, substitutions): expr0, expr1 = _substitute(lhs, rhs, substitutions) if expr0.cancel().simplify().simplify() != 0: - result = (expr0/expr1).cancel().simplify().is_constant() - ratio = expr0/expr1 + ratio = (expr0/expr1).cancel().simplify() + result = ratio.is_constant() elif expr0.cancel().simplify().simplify() != 0: - result = (expr1/expr0).cancel().simplify().is_constant() - ratio = expr1/expr0 + ratio = (expr1/expr0).cancel().simplify() + result = ratio.is_constant() else: result = False if result is True: - ratio = float(ratio.cancel().simplify()) + ratio = float(ratio) else: ratio = None return result, ratio diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index 098ed28..a33520f 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -1,9 +1,9 @@ from lambdafeedback.criteria import CriteriaGraph -from lambdafeedback.expression_parsing import compute_relative_tolerance_from_significant_decimals, parse_expression +from lambdafeedback.expression_parsing import compute_relative_tolerance_from_significant_decimals from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ..comparison import comparison_function, is_proportional +from ..comparison import comparison_function, is_proportional, parse_criterion_sides is_equal = comparison_function("=") @@ -242,17 +242,13 @@ def _add_match_nodes(graph, END, label, inputs, quantity_match, dimension_match, def criterion_match_node(criterion, parameters, label=None): graph = CriteriaGraph(label) END = graph.add_end_node() - parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) rtol, atol = _tolerances(parameters) if label is None: label = criterion.content_string() inputs = criterion.children - lhs_string = criterion.children[0].content_string() - rhs_string = criterion.children[1].content_string() - lhs = parse_expression(lhs_string, parsing_params) - rhs = parse_expression(rhs_string, parsing_params) + lhs_string, rhs_string, lhs, rhs = parse_criterion_sides(criterion, parameters) _add_match_nodes( graph, diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index c27e411..63e6722 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -1,25 +1,20 @@ from lambdafeedback.criteria import CriteriaGraph -from lambdafeedback.expression_parsing import parse_expression from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ..comparison import comparison_function +from ..comparison import comparison_function, parse_criterion_sides def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): graph = CriteriaGraph(label) END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() - parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) if label is None: label = criterion.content_string() inputs = criterion.children - lhs_string = criterion.children[0].content_string() - rhs_string = criterion.children[1].content_string() - lhs = parse_expression(lhs_string, parsing_params) - rhs = parse_expression(rhs_string, parsing_params) + _, _, lhs, rhs = parse_criterion_sides(criterion, parameters) stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] compare = comparison_function(comparison_operator) @@ -62,8 +57,7 @@ def greater_than_node(criterion, parameters, label=None): def greater_than_or_equal_node(criterion, parameters, label=None): # TODO: Add nodes for the equal case - graph = comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) - return graph + return comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) def less_than_node(criterion, parameters, label=None): @@ -72,5 +66,4 @@ def less_than_node(criterion, parameters, label=None): def less_than_or_equal_node(criterion, parameters, label=None): # TODO: Add nodes for the equal case - graph = comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label) - return graph + return comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label) diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 000b46a..2d16df2 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from lambdafeedback.expression_parsing import ( ExpressionParams, SympyParsingConfig, @@ -6,7 +8,7 @@ ) from ...preview.symbolic import preview_function -from ..graph_helpers import disable_nodes +from ..defaults import base_default_parameters from .graphs.equivalence import criterion_equality_node from .graphs.generic import criterion_eval_node from .graphs.substitution import criterion_where_node @@ -15,13 +17,7 @@ from lambdafeedback.criteria import build_criteria_parser -default_parameters = { - "complexNumbers": False, - "convention": "equal_precedence", - "elementary_functions": False, - "strict_syntax": False, - "multiple_answers_criteria": "all", -} +default_parameters = deepcopy(base_default_parameters) def expression_preprocess(name, expr, parameters): @@ -48,9 +44,7 @@ def feedback_procedure_generator(parameters_dict): criteria = set(parameters_dict["criteria"].items()) for (label, criterion) in criteria: graph_template = graph_templates.get(criterion.label, criterion_eval_node) - graph = graph_template(criterion, parameters_dict) - disable_nodes(graph, parameters_dict.get("disabled_evaluation_nodes", set())) - graphs.update({label: graph}) + graphs.update({label: graph_template(criterion, parameters_dict)}) return graphs diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 401f28e..efd6963 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -1,3 +1,5 @@ +import operator + from sympy import Equality, im, I from sympy import re as real_part @@ -33,9 +35,8 @@ def parse_where_substitutions(subs_node, parsing_params): return local_subs -def check_criterion(criterion, parameters_dict, generate_feedback=True): +def check_criterion(criterion, parameters_dict): label = criterion.label.strip() - parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) if label in {"EQUALITY", "WRITTEN_AS"}: result = check_equality(criterion, parameters_dict) elif label == "ORDER": @@ -46,17 +47,17 @@ def check_criterion(criterion, parameters_dict, generate_feedback=True): result = check_proportionality(criterion, parameters_dict) elif label == "WHERE": crit = criterion.children[0] + parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) local_subs = parse_where_substitutions(criterion.children[1], parsing_params) - result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}, generate_feedback) + result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}) else: raise Exception(label) return result def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): - parsing_params = parameters_dict["parsing_parameters"] reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) + parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) @@ -86,17 +87,19 @@ def do_comparison_infinite(comparison_symbol, lhs_expr, rhs_expr): return None +COMPARISON_OPERATORS = { + "=": operator.eq, + ">": operator.gt, + ">=": operator.ge, + "<": operator.lt, + "<=": operator.le, +} + + def do_comparison(comparison_symbol, expression): - comparisons = { - "=": lambda expr: bool(expression.cancel().simplify().simplify() == 0), - ">": lambda expr: bool(expression.cancel().simplify().simplify() > 0), - ">=": lambda expr: bool(expression.cancel().simplify().simplify() >= 0), - "<": lambda expr: bool(expression.cancel().simplify().simplify() < 0), - "<=": lambda expr: bool(expression.cancel().simplify().simplify() <= 0), - } - comparison = comparisons[comparison_symbol.strip()] + comparison = COMPARISON_OPERATORS[comparison_symbol.strip()] try: - result = comparison(expression) + result = bool(comparison(expression.cancel().simplify().simplify(), 0)) except Exception: result = None return result @@ -121,10 +124,18 @@ def check_equality(criterion, parameters_dict, local_substitutions=[]): # There are some types of expression, e.g. those containing hyperbolic trigonometric functions, that can behave # unpredictably when simplification is applied. For that reason we check several different combinations of # simplifications here in order to reduce the likelihood of false negatives. + # Each side is simplified at most once, and only if a retry needs it + simplified = {} + + def simplified_side(side, expr): + if side not in simplified: + simplified[side] = expr.simplify() + return simplified[side] + differences_to_retry = ( - lambda: lhs_expr-rhs_expr.simplify(), - lambda: lhs_expr.simplify()-rhs_expr, - lambda: lhs_expr.simplify()-rhs_expr.simplify(), + lambda: lhs_expr-simplified_side("rhs", rhs_expr), + lambda: simplified_side("lhs", lhs_expr)-rhs_expr, + lambda: simplified_side("lhs", lhs_expr)-simplified_side("rhs", rhs_expr), ) for difference in differences_to_retry: if result is not False: diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index a791abd..34d7c6c 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -27,10 +27,11 @@ def equality_equivalence(unused_input): # TODO: Remove when criteria for checking proportionality is implemented if isinstance(res, Equality) and isinstance(ans, Equality): - if (res.args[0]-res.args[1]).simplify() == 0: + res_difference = (res.args[0]-res.args[1]).simplify() + if res_difference == 0: symbols_in_equality_ratio = (ans.args[0]-ans.args[1]).simplify().free_symbols elif (ans.args[0]-ans.args[1]).simplify() == 0: - symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols + symbols_in_equality_ratio = res_difference.free_symbols else: symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) @@ -40,9 +41,8 @@ def equality_equivalence(unused_input): def _same_symbols(label, lhs, rhs, parameters_dict): def same_symbols(unused_input): - parsing_params = parameters_dict["parsing_parameters"] local_substitutions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) + parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols @@ -185,9 +185,7 @@ def criterion_equality_node(criterion, parameters_dict, label=None): use_set_equivalence, response_list, answer_list = as_sets(res, ans) if use_set_equivalence is True: - add_set_equivalence_nodes( - graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list, check_equality - ) + add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list) elif isinstance(res, Equality) or isinstance(ans, Equality): add_equality_equivalence_nodes(graph, END, parameters_dict, label, lhs, rhs) else: diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index 4ca267b..c93f8af 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -5,14 +5,14 @@ from ..comparison import check_criterion -def criterion_eval_node(criterion, parameters_dict, generate_feedback=True): +def criterion_eval_node(criterion, parameters_dict): feedback_string_generator_inputs = {'criterion': criterion} + label = criterion.content_string() def evaluation_node_internal(unused_input): - result = check_criterion(criterion, parameters_dict, generate_feedback) - return outcome_tag(criterion.content_string(), result, feedback_string_generator_inputs) + result = check_criterion(criterion, parameters_dict) + return outcome_tag(label, result, feedback_string_generator_inputs) - label = criterion.content_string() graph = CriteriaGraph(label) END = graph.add_end_node() graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index a3e4197..0025e0e 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -2,6 +2,7 @@ from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ...graph_helpers import attach_terminal_criterion +from ..comparison import check_equality def as_sets(response, answer): @@ -11,29 +12,24 @@ def as_sets(response, answer): """ if isinstance(response, set) and isinstance(answer, set): return True, response, answer - elif isinstance(response, set) and not isinstance(answer, set): + elif isinstance(response, set): return True, response, set([answer]) - elif not isinstance(response, set) and isinstance(answer, set): + elif isinstance(answer, set): return True, set([response]), answer return False, response, answer -def add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs, - response_list, answer_list, check_equality): +def add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list): """ Adds the nodes that check if each element of `response_list` is equal to an element of `answer_list` (and vice versa, depending on `multiple_answers_criteria`) to `graph`. - `check_equality` compares a single response with a single answer. """ def set_equivalence(unused_input): matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} for i, response in enumerate(response_list): - result = None for j, answer in enumerate(answer_list): current_pair = [("response", response), ("answer", answer)] - if isinstance(response, Equality) and not isinstance(answer, Equality): - result = False - elif not isinstance(response, Equality) and isinstance(answer, Equality): + if isinstance(response, Equality) != isinstance(answer, Equality): result = False else: result = check_equality(criterion, parameters_dict, local_substitutions=current_pair) diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 2fef27d..8d58974 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -20,43 +20,47 @@ def _expression_to_vary(expression): def _variation_groups(criterion, expression_to_vary): + expression_string = criterion.children[0].content_string() return { "ONE_ADDITION_TO_SUBTRACTION": { "variations": one_addition_to_subtraction(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" if one addition is changed to a subtraction or vice versa.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + "summary": expression_string+" if one addition is changed to a subtraction or vice versa.", }, "ONE_EXPONENT_FLIP": { "variations": one_exponent_flip(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one exponent has its sign changed.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + "summary": expression_string+" is true if one exponent has its sign changed.", }, "ONE_SWAP_ADDITION_AND_MULTIPLICATION": { "variations": one_swap_addition_and_multiplication(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one addition is replaced with a multiplication or vice versa.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + "summary": expression_string+" is true if one addition is replaced with a multiplication or vice versa.", } } +def _variation_details(variations): + return "The following expressions are checked: "+", ".join([str(e) for e in variations]) + + def _group_values(expression_to_vary, variation_groups, local_subs): """ Groups `expression_to_vary` and its variations by their value after `local_subs` are applied. Returns the expressions for each value, and the variation groups that produce each value. """ - value = expression_to_vary.subs(local_subs).simplify() - values_and_expressions = {str(value): set([expression_to_vary])} - values_and_variations_group = {str(value): set(["UNKNOWN"])} + unvaried_value = expression_to_vary.subs(local_subs) + value_key = str(unvaried_value.simplify()) + values_and_expressions = {value_key: set([expression_to_vary])} + values_and_variations_group = {value_key: set(["UNKNOWN"])} for (group_label, info) in variation_groups.items(): for variation in info["variations"]: value = variation.subs(local_subs).simplify() - values_and_expressions.update({str(value): values_and_expressions.get(str(value), set()).union(set([variation]))}) - if value != expression_to_vary.subs(local_subs): - values_and_variations_group.update({str(value): values_and_variations_group.get(str(value), set()).union(set([group_label]))}) + value_key = str(value) + values_and_expressions.setdefault(value_key, set()).add(variation) + if value != unvaried_value: + values_and_variations_group.setdefault(value_key, set()).add(group_label) return values_and_expressions, values_and_variations_group -def _add_identify_reason_nodes(graph, END, label, criterion, expression, expression_to_vary, variation_groups, +def _add_identify_reason_nodes(graph, END, label, criterion, expression, variation_groups, values_and_expressions, values_and_variations_group, response_value): """Adds the nodes that try to identify which variation of the expression the response matches.""" def identify_reason(unused_input): @@ -91,8 +95,8 @@ def get_candidates(unused_input): graph.attach_new_criterion( label+"_IDENTIFY_REASON", label+"_"+group_label, - summary=group_info["summary"](expression_to_vary, group_info["variations"]), - details=group_info["details"](expression_to_vary, group_info["variations"]), + summary=group_info["summary"], + details=_variation_details(group_info["variations"]), feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) graph.attach_new_evaluation( @@ -131,12 +135,13 @@ def expression_check(unused_input): result = check_equality(expression, parameters_dict, local_substitutions=local_subs) return outcome_tag(label, result is True) + subs_string = ", ".join([s.content_string() for s in subs]) graph = CriteriaGraph(label) END = graph.add_end_node() graph.add_evaluation_node( label, summary=label, - details="Checks if "+expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+".", + details="Checks if "+expression.content_string()+" where "+subs_string+".", evaluate=expression_check ) attach_terminal_criterion( @@ -144,15 +149,15 @@ def expression_check(unused_input): label, label+"_TRUE", END, - summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), - details=expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+"is true.", + summary=expression.content_string()+" where "+subs_string, + details=expression.content_string()+" where "+subs_string+"is true.", feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") ) graph.attach_new_criterion( label, label+"_FALSE", summary="not "+expression.content_string(), - details=expression.content_string()+" is not true when "+", ".join([s.content_string() for s in subs])+".", + details=expression.content_string()+" is not true when "+subs_string+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("FALSE") ) @@ -167,7 +172,7 @@ def expression_check(unused_input): values_and_expressions, values_and_variations_group = _group_values(expression_to_vary, variation_groups, local_subs) if len(values_and_expressions) > 1: _add_identify_reason_nodes( - graph, END, label, criterion, expression, expression_to_vary, variation_groups, + graph, END, label, criterion, expression, variation_groups, values_and_expressions, values_and_variations_group, response_value ) return graph diff --git a/app/docs/dev.md b/app/docs/dev.md index 58fae8a..e167733 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -48,7 +48,7 @@ The overall flow of the evaluation procedure can be described as follows: ### Context The context is a data structure that contains at least the following seven pieces of information: -- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `context/symbolic/__init__.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context/physical_quantity/__init__.py`. +- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts start from the default parameters found in `context/defaults.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context/physical_quantity/__init__.py`. - `expression_parse` function that parses expressions (i.e. the `response` and `answer` inputs) into the form used by the feedback generation procedure. - `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. - `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. diff --git a/app/evaluation.py b/app/evaluation.py index fe2e61b..abf0e1d 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,5 +1,6 @@ from copy import deepcopy +from lambdafeedback.criteria import CriteriaGraph from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, @@ -191,6 +192,13 @@ def create_criteria_dict(criteria_parser, parsing_params): return criteria_parsed +def _disable_evaluation_nodes(graph, disabled_evaluation_nodes): + """Makes each evaluation in `disabled_evaluation_nodes` skip straight to the end of `graph`.""" + for evaluation in graph.evaluations.values(): + if evaluation.label in disabled_evaluation_nodes: + evaluation.replacement = CriteriaGraph.END_LABEL + + def generate_feedback(criteria_graphs, evaluation_parameters): # Generate feedback from criteria graphs evaluation_result = evaluation_parameters["evaluation_result"] @@ -365,6 +373,8 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di # Performs evaluation of response feedback_procedures = context["feedback_procedure_generator"](evaluation_parameters) + for graph in feedback_procedures.values(): + _disable_evaluation_nodes(graph, evaluation_parameters["disabled_evaluation_nodes"]) generate_feedback(feedback_procedures, evaluation_parameters) result = evaluation_result.serialise(include_test_data) From b3bff03bed0983cbab1d17e8f6ee7a580a6b4e27 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Thu, 15 Oct 2026 16:18:50 +0100 Subject: [PATCH 11/25] Refactor operators and evaluation logic: centralize `COMPARISON_OPERATORS` in `app/context/operators.py`, replace `unused_input` with `_response` across evaluation methods, remove redundancy in comparison and substitution functions, and streamline parameter handling in criteria-related methods. --- app/context/operators.py | 11 +++++++ app/context/physical_quantity/comparison.py | 12 ++----- app/context/physical_quantity/graphs/match.py | 6 ++-- app/context/physical_quantity/graphs/order.py | 4 +-- app/context/symbolic/comparison.py | 33 +++++-------------- app/context/symbolic/graphs/equivalence.py | 6 ++-- app/context/symbolic/graphs/generic.py | 2 +- .../symbolic/graphs/set_equivalence.py | 2 +- app/context/symbolic/graphs/substitution.py | 6 ++-- .../symbolic/graphs/syntactical_form.py | 6 ++-- app/evaluation.py | 9 +++-- app/result.py | 3 -- 12 files changed, 42 insertions(+), 58 deletions(-) create mode 100644 app/context/operators.py diff --git a/app/context/operators.py b/app/context/operators.py new file mode 100644 index 0000000..0a43134 --- /dev/null +++ b/app/context/operators.py @@ -0,0 +1,11 @@ +"""The comparison operators that criteria can use, by the symbol used in the criteria string.""" + +import operator + +COMPARISON_OPERATORS = { + "=": operator.eq, + ">": operator.gt, + ">=": operator.ge, + "<": operator.lt, + "<=": operator.le, +} diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index 54db004..c2892fa 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -1,18 +1,10 @@ -import operator - from sympy import Symbol from lambdafeedback.expression_parsing import parse_expression -NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') +from ..operators import COMPARISON_OPERATORS -COMPARISON_OPERATORS = { - "=": operator.eq, - ">": operator.gt, - "<": operator.lt, - ">=": operator.ge, - "<=": operator.le, -} +NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') def parse_criterion_sides(criterion, parameters): diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index a33520f..a01417f 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -59,7 +59,7 @@ def _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol): def _quantity_match(parameters, label, lhs, rhs, lhs_string, rhs_string, rtol, atol): reserved_expressions = parameters["reserved_expressions"].items() - def quantity_match(unused_inputs): + def quantity_match(_response): missing_or_unexpected = _missing_or_unexpected(parameters, label, lhs_string, rhs_string) if missing_or_unexpected is not None: return missing_or_unexpected @@ -79,7 +79,7 @@ def quantity_match(unused_inputs): def _dimension_match(parameters, label, lhs, rhs, lhs_string, rhs_string): reserved_expressions = parameters["reserved_expressions"].items() - def dimension_match(unused_inputs): + def dimension_match(_response): substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] dimension_match, _ = is_proportional(lhs, rhs, substitutions) return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) @@ -89,7 +89,7 @@ def dimension_match(unused_inputs): def _unit_comparison(parameters, label, lhs, rhs): reserved_expressions = parameters["reserved_expressions"].items() - def unit_comparison(unused_inputs): + def unit_comparison(_response): substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] if is_equal(lhs, rhs, substitutions) is True: diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index 63e6722..fe154a5 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -5,7 +5,7 @@ from ..comparison import comparison_function, parse_criterion_sides -def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): +def comparison_base_graph(criterion, parameters, comparison_operator, label=None): graph = CriteriaGraph(label) END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() @@ -19,7 +19,7 @@ def comparison_base_graph(criterion, parameters, comparison_operator="=", label= stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] compare = comparison_function(comparison_operator) - def compare_evaluate(unused_input): + def compare_evaluate(_response): return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True) graph.add_evaluation_node( diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index efd6963..ab8ba17 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -1,10 +1,9 @@ -import operator - from sympy import Equality, im, I from sympy import re as real_part from lambdafeedback.expression_parsing import parse_expression +from ..operators import COMPARISON_OPERATORS from .numerical import compare_numerically @@ -45,17 +44,12 @@ def check_criterion(criterion, parameters_dict): result = check_contains_symbol(criterion, parameters_dict) elif label == "PROPORTIONAL_TO": result = check_proportionality(criterion, parameters_dict) - elif label == "WHERE": - crit = criterion.children[0] - parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) - local_subs = parse_where_substitutions(criterion.children[1], parsing_params) - result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}) else: raise Exception(label) return result -def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): +def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=()): reserved_expressions = list(parameters_dict["reserved_expressions"].items()) parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) lhs = criterion.children[0].content_string() @@ -87,15 +81,6 @@ def do_comparison_infinite(comparison_symbol, lhs_expr, rhs_expr): return None -COMPARISON_OPERATORS = { - "=": operator.eq, - ">": operator.gt, - ">=": operator.ge, - "<": operator.lt, - "<=": operator.le, -} - - def do_comparison(comparison_symbol, expression): comparison = COMPARISON_OPERATORS[comparison_symbol.strip()] try: @@ -105,7 +90,7 @@ def do_comparison(comparison_symbol, expression): return result -def check_equality(criterion, parameters_dict, local_substitutions=[]): +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): return False @@ -148,14 +133,14 @@ def simplified_side(side, expr): return result -def check_order(criterion, parameters_dict, local_substitutions=[]): - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) +def check_order(criterion, parameters_dict): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = do_comparison(criterion.content, lhs_expr-rhs_expr) return result -def check_proportionality(criterion, parameters_dict, local_substitutions=[]): - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) +def check_proportionality(criterion, parameters_dict): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = None if lhs_expr.cancel().simplify().simplify() != 0: result = (rhs_expr/lhs_expr).cancel().simplify() @@ -168,7 +153,7 @@ def check_proportionality(criterion, parameters_dict, local_substitutions=[]): return result -def check_contains_symbol(criterion, parameters_dict, local_substitutions=[]): - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) +def check_contains_symbol(criterion, parameters_dict): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = rhs_expr in lhs_expr.atoms() return result diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index 34d7c6c..3df2cdf 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -10,7 +10,7 @@ def _equality_equivalence(label, parameters_dict): - def equality_equivalence(unused_input): + def equality_equivalence(_response): result = False res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] @@ -40,7 +40,7 @@ def equality_equivalence(unused_input): def _same_symbols(label, lhs, rhs, parameters_dict): - def same_symbols(unused_input): + def same_symbols(_response): local_substitutions = list(parameters_dict["reserved_expressions"].items()) parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) @@ -110,7 +110,7 @@ def add_mathematical_equivalence_nodes(graph, END, criterion, parameters_dict, l Adds the nodes that check if `lhs` and `rhs` are mathematically equal to `graph`, and if they are, whether they have the same symbols. """ - def mathematical_equivalence(unused_input): + def mathematical_equivalence(_response): return outcome_tag(label, check_equality(criterion, parameters_dict)) graph.add_evaluation_node( diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index c93f8af..e00be16 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -9,7 +9,7 @@ def criterion_eval_node(criterion, parameters_dict): feedback_string_generator_inputs = {'criterion': criterion} label = criterion.content_string() - def evaluation_node_internal(unused_input): + def evaluation_node_internal(_response): result = check_criterion(criterion, parameters_dict) return outcome_tag(label, result, feedback_string_generator_inputs) diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index 0025e0e..67c6c2c 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -24,7 +24,7 @@ def add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs Adds the nodes that check if each element of `response_list` is equal to an element of `answer_list` (and vice versa, depending on `multiple_answers_criteria`) to `graph`. """ - def set_equivalence(unused_input): + def set_equivalence(_response): matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} for i, response in enumerate(response_list): for j, answer in enumerate(answer_list): diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 8d58974..46b0d1e 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -63,7 +63,7 @@ def _group_values(expression_to_vary, variation_groups, local_subs): def _add_identify_reason_nodes(graph, END, label, criterion, expression, variation_groups, values_and_expressions, values_and_variations_group, response_value): """Adds the nodes that try to identify which variation of the expression the response matches.""" - def identify_reason(unused_input): + def identify_reason(_response): reasons = { label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) } @@ -85,7 +85,7 @@ def identify_reason(unused_input): feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) - def get_candidates(unused_input): + def get_candidates(_response): candidates = { label+"_RESPONSE_CANDIDATES_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] } @@ -131,7 +131,7 @@ def criterion_where_node(criterion, parameters_dict, label=None): if label is None: label = criterion.content_string() - def expression_check(unused_input): + def expression_check(_response): result = check_equality(expression, parameters_dict, local_substitutions=local_subs) return outcome_tag(label, result is True) diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index d892868..2330711 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -7,7 +7,7 @@ def written_as_known_form(string, name): - def find_form(unused_input): + def find_form(_response): forms = set() for form_label in syntactical_forms.keys(): if syntactical_forms[form_label].matcher(string) is True: @@ -20,13 +20,13 @@ def find_form(unused_input): def compare_to_form(string, form_label, label): - def inner(unused_input): + def inner(_response): return outcome_tag(label, syntactical_forms[form_label].matcher(string) is True) return inner def response_written_as_answer(response, answer, label): - def inner(unused_input): + def inner(_response): matcher = generate_arbitrary_number_pattern_matcher(answer) return outcome_tag(label, bool(matcher(response))) return inner diff --git a/app/evaluation.py b/app/evaluation.py index abf0e1d..6cdded2 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -173,11 +173,11 @@ def split_criteria(criteria_string): return criteria_string_list -def create_criteria_dict(criteria_parser, parsing_params): - preprocess = parsing_params["context"]["expression_preprocess"] - original_criteria_string = get_criteria_string(parsing_params) +def create_criteria_dict(criteria_parser, parameters): + preprocess = parameters["context"]["expression_preprocess"] + original_criteria_string = get_criteria_string(parameters) try: - criteria_string = preprocess("criteria", original_criteria_string, parsing_params) + criteria_string = preprocess("criteria", original_criteria_string, parameters) except (BracketNotationError, AbsoluteValueNotationError) as e: feedback = notation_error_feedback(e, original_criteria_string) raise Exception(internal_feedback(feedback), str(feedback.tag)) from e @@ -331,7 +331,6 @@ def _build_evaluation_parameters(parameters, reserved_expressions, criteria, eva "disabled_evaluation_nodes": parameters.get("disabled_evaluation_nodes", set()), "parsing_parameters": parameters["parsing_parameters"], "evaluation_result": evaluation_result, - "syntactical_comparison": parameters.get("syntactical_comparison", False), "multiple_answers_criteria": parameters.get("multiple_answers_criteria", "all"), "numerical": parameters.get("numerical", False), "atol": parameters.get("atol", 0), diff --git a/app/result.py b/app/result.py index 081d837..d926773 100644 --- a/app/result.py +++ b/app/result.py @@ -20,9 +20,6 @@ def __init__(self): def tags(self): return list(dict.fromkeys(tag for (tag, _) in self._feedback)) - def get_feedback(self, tag): - return [text for (t, text) in self._feedback if t == tag] - def add_feedback(self, tag, feedback): """Record `feedback` under `tag`. Blank feedback records the tag without adding any text.""" self._feedback.append((tag, feedback or "")) From 87879b43434b567ea9fda7e519be47cad489bca6 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 10:01:34 +0100 Subject: [PATCH 12/25] Introduce type annotations for all modules in `app/`, add `mypy.ini` for static type checking, enforce stricter type safety across the codebase, and integrate mypy checks into the CI workflow. --- .github/workflows/test-lint.yml | 7 +++ app/conftest.py | 5 +- app/context/graph_helpers.py | 19 +++++++- app/context/physical_quantity/__init__.py | 18 ++++++-- app/context/physical_quantity/comparison.py | 22 ++++++--- .../physical_quantity/criteria_parser.py | 9 +++- app/context/physical_quantity/graphs/match.py | 45 ++++++++++++------ app/context/physical_quantity/graphs/order.py | 24 ++++++---- app/context/physical_quantity/parsing.py | 10 +++- app/context/symbolic/__init__.py | 23 +++++++--- app/context/symbolic/comparison.py | 46 ++++++++++++------- app/context/symbolic/graphs/equivalence.py | 28 +++++++---- app/context/symbolic/graphs/generic.py | 12 +++-- .../symbolic/graphs/set_equivalence.py | 20 ++++++-- app/context/symbolic/graphs/substitution.py | 38 ++++++++++----- .../symbolic/graphs/syntactical_form.py | 24 ++++++---- app/context/symbolic/numerical.py | 10 ++-- app/context/symbolic/variations.py | 28 ++++++----- app/context/types.py | 27 +++++++++++ app/docs/dev.md | 5 +- app/evaluation.py | 45 +++++++++++------- app/evaluation_tests.py | 2 +- app/feedback/physical_quantity.py | 11 ++++- app/feedback/symbolic.py | 37 +++++---------- app/frozen_dict.py | 16 ++++--- app/result.py | 26 ++++++----- mypy.ini | 21 +++++++++ 27 files changed, 400 insertions(+), 178 deletions(-) create mode 100644 app/context/types.py create mode 100644 mypy.ini diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml index 0f1e91d..ec896e7 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -40,6 +40,13 @@ jobs: # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 ./app --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Type check with mypy + run: | + source .venv/bin/activate + .venv/bin/pip install mypy==1.15.0 + # configuration (checked files, strictness, untyped dependencies) is in mypy.ini + mypy + - name: Run tests if: always() run: | diff --git a/app/conftest.py b/app/conftest.py index adb5e82..fb042f2 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -1,2 +1,5 @@ -def pytest_configure(config): +import pytest + + +def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", "slow: excluded from the deployed healthcheck (see evaluation_tests.py)") diff --git a/app/context/graph_helpers.py b/app/context/graph_helpers.py index b570a87..5241a22 100644 --- a/app/context/graph_helpers.py +++ b/app/context/graph_helpers.py @@ -1,7 +1,14 @@ """Helpers for building the `CriteriaGraph`s shared by the symbolic and physical quantity contexts.""" +from __future__ import annotations -def outcome_tag(label, result, inputs=None): +from collections.abc import Mapping +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator, OutputNode + + +def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | None = None) -> dict[str, Mapping[str, Any] | None]: """`{label+"_TRUE": inputs}` if `result` is True, `label+"_FALSE"` if it is False, otherwise `label+"_UNKNOWN"`.""" if result is True: return {label+"_TRUE": inputs} @@ -11,7 +18,15 @@ def outcome_tag(label, result, inputs=None): return {label+"_UNKNOWN": inputs} -def attach_terminal_criterion(graph, evaluation_label, label, end, summary, details, feedback_string_generator=None): +def attach_terminal_criterion( + graph: CriteriaGraph, + evaluation_label: str, + label: str, + end: OutputNode, + summary: str, + details: str, + feedback_string_generator: FeedbackStringGenerator | None = None, +) -> None: """Attaches a new criterion `label` to `evaluation_label` and connects it to the `end` node.""" graph.attach_new_criterion( evaluation_label, diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index 948c636..0ce8492 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -1,21 +1,27 @@ +from __future__ import annotations + +from collections.abc import Iterable from copy import deepcopy +from typing import Any +from lambdafeedback.criteria import CriteriaGraph from lambdafeedback.expression_parsing import ExpressionParams, SympyParsingConfig from lambdafeedback.units import QuantityParams, preprocess_quantity from ...preview.physical_quantity import preview_function from ..defaults import base_default_parameters +from ..types import GraphTemplate, Parameters from .criteria_parser import generate_criteria_parser from .graphs.match import criterion_match_node from .graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node from .parsing import parse_quantity -def expression_preprocess(name, expr, parameters): +def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str: return preprocess_quantity(expr, QuantityParams.from_dict(parameters), name=name) -graph_templates = { +graph_templates: dict[str, GraphTemplate] = { "matches": criterion_match_node, ">": greater_than_node, ">=": greater_than_or_equal_node, @@ -24,7 +30,7 @@ def expression_preprocess(name, expr, parameters): } -def feedback_procedure_generator(parameters_dict): +def feedback_procedure_generator(parameters_dict: Parameters) -> dict[str, CriteriaGraph]: graphs = dict() for (label, criterion) in parameters_dict["criteria"].items(): graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) @@ -47,7 +53,9 @@ def feedback_procedure_generator(parameters_dict): ) -def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): +def parsing_parameters_generator( + params: Parameters, unsplittable_symbols: Iterable[str] = (), symbol_assumptions: Iterable[tuple[str, str]] = () +) -> dict[str, Any]: return { "sympy": SympyParsingConfig.from_params(ExpressionParams.from_dict(params)), "quantity": QuantityParams.from_dict({key: params[key] for key in QUANTITY_PARSING_PARAMETERS if key in params}), @@ -70,7 +78,7 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as default_criteria = ["response matches answer"] -context = { +context: dict[str, Any] = { "expression_preview": preview_function, "generate_criteria_parser": generate_criteria_parser, "expression_preprocess": expression_preprocess, diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index c2892fa..231cfbb 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -1,13 +1,23 @@ -from sympy import Symbol +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from sympy import Basic, Symbol from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.slr_parsing import ExprNode from ..operators import COMPARISON_OPERATORS +from ..types import Parameters + +# Substitutions where a value may be missing (None) +OptionalSubstitutions = Iterable[tuple[str, Any]] NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') -def parse_criterion_sides(criterion, parameters): +def parse_criterion_sides(criterion: ExprNode, parameters: Parameters) -> tuple[str, str, Basic, Basic]: """The left- and right-hand sides of `criterion` as strings, and parsed without simplification.""" parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) lhs_string = criterion.children[0].content_string() @@ -15,22 +25,22 @@ def parse_criterion_sides(criterion, parameters): return lhs_string, rhs_string, parse_expression(lhs_string, parsing_params), parse_expression(rhs_string, parsing_params) -def _substitute(lhs, rhs, substitutions): +def _substitute(lhs: Basic, rhs: Basic, substitutions: OptionalSubstitutions) -> tuple[Basic, Basic]: """`lhs` and `rhs` with `substitutions` applied, where substitutions by None use a placeholder symbol instead.""" local_substitutions = [(key, NONE_PLACEHOLDER) if expr is None else (key, expr) for (key, expr) in substitutions] return lhs.subs(local_substitutions), rhs.subs(local_substitutions) -def comparison_function(comparison_operator): +def comparison_function(comparison_operator: str) -> Callable[[Basic, Basic, OptionalSubstitutions], bool]: comparison = COMPARISON_OPERATORS[comparison_operator] - def comparison_function_inner(lhs, rhs, substitutions): + def comparison_function_inner(lhs: Basic, rhs: Basic, substitutions: OptionalSubstitutions) -> bool: expr0, expr1 = _substitute(lhs, rhs, substitutions) return bool(comparison((expr0-expr1).cancel().simplify().simplify(), 0)) return comparison_function_inner -def is_proportional(lhs, rhs, substitutions): +def is_proportional(lhs: Basic, rhs: Basic, substitutions: OptionalSubstitutions) -> tuple[bool | None, float | None]: expr0, expr1 = _substitute(lhs, rhs, substitutions) if expr0.cancel().simplify().simplify() != 0: ratio = (expr0/expr1).cancel().simplify() diff --git a/app/context/physical_quantity/criteria_parser.py b/app/context/physical_quantity/criteria_parser.py index f30720a..677bce4 100644 --- a/app/context/physical_quantity/criteria_parser.py +++ b/app/context/physical_quantity/criteria_parser.py @@ -1,7 +1,12 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate -def generate_criteria_parser(reserved_expressions): +def generate_criteria_parser(reserved_expressions: Mapping[str, Any]) -> SLRParser: start_symbol = "START" end_symbol = "END" null_symbol = "NULL" @@ -16,7 +21,7 @@ def generate_criteria_parser(reserved_expressions): ">", } - token_list = [ + token_list: list[TokenPattern | CatchAll] = [ TokenPattern(start_symbol, start_symbol), TokenPattern(end_symbol, end_symbol), TokenPattern(null_symbol, null_symbol), diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index a01417f..08bb93d 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -1,14 +1,23 @@ -from lambdafeedback.criteria import CriteriaGraph +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from sympy import Basic + +from lambdafeedback.criteria import CriteriaGraph, Evaluate, OutputNode, ReachedCriteria from lambdafeedback.expression_parsing import compute_relative_tolerance_from_significant_decimals +from lambdafeedback.slr_parsing import ExprNode from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ...types import Parameters from ..comparison import comparison_function, is_proportional, parse_criterion_sides is_equal = comparison_function("=") -def _tolerances(parameters): +def _tolerances(parameters: Parameters) -> tuple[float, float]: """`(rtol, atol)`, with `rtol` taken from the significant figures of the answer if no tolerance is given.""" rtol = parameters["parsing_parameters"]["rtol"] atol = parameters["parsing_parameters"]["atol"] @@ -19,7 +28,7 @@ def _tolerances(parameters): return rtol, atol -def _missing_or_unexpected(parameters, label, lhs_string, rhs_string): +def _missing_or_unexpected(parameters: Parameters, label: str, lhs_string: str, rhs_string: str) -> ReachedCriteria | None: """Tag for a response missing a value or unit that the answer has (or vice versa), otherwise None.""" # TODO: Better system for identifying if we expect the response to have value/unit or not if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string): @@ -38,7 +47,7 @@ def _missing_or_unexpected(parameters, label, lhs_string, rhs_string): return None -def _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol): +def _within_tolerance(parameters: Parameters, lhs_string: str, rhs_string: str, rtol: float, atol: float) -> bool: """Whether the standard values of the response and answer are within the numerical tolerances.""" value_match = False # TODO: better analysis of where `answer` is found in the criteria so that @@ -56,10 +65,12 @@ def _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol): return value_match -def _quantity_match(parameters, label, lhs, rhs, lhs_string, rhs_string, rtol, atol): +def _quantity_match( + parameters: Parameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str, rtol: float, atol: float +) -> Evaluate: reserved_expressions = parameters["reserved_expressions"].items() - def quantity_match(_response): + def quantity_match(_response: Any) -> ReachedCriteria: missing_or_unexpected = _missing_or_unexpected(parameters, label, lhs_string, rhs_string) if missing_or_unexpected is not None: return missing_or_unexpected @@ -76,20 +87,20 @@ def quantity_match(_response): return quantity_match -def _dimension_match(parameters, label, lhs, rhs, lhs_string, rhs_string): +def _dimension_match(parameters: Parameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str) -> Evaluate: reserved_expressions = parameters["reserved_expressions"].items() - def dimension_match(_response): + def dimension_match(_response: Any) -> ReachedCriteria: substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] dimension_match, _ = is_proportional(lhs, rhs, substitutions) return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) return dimension_match -def _unit_comparison(parameters, label, lhs, rhs): +def _unit_comparison(parameters: Parameters, label: str, lhs: Basic, rhs: Basic) -> Evaluate: reserved_expressions = parameters["reserved_expressions"].items() - def unit_comparison(_response): + def unit_comparison(_response: Any) -> ReachedCriteria: substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] if is_equal(lhs, rhs, substitutions) is True: @@ -99,11 +110,12 @@ def unit_comparison(_response): else: local_substitutions = [(key, expr["quantity"].expanded_unit) for (key, expr) in reserved_expressions] result, ratio = is_proportional(lhs, rhs, local_substitutions) - if result is True and ratio >= 1000: + # `is_proportional` returns a ratio whenever the result is True + if result is True and ratio is not None and ratio >= 1000: output_tags = { label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE": None } - elif result is True and ratio <= 1/1000: + elif result is True and ratio is not None and ratio <= 1/1000: output_tags = { label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL": None } @@ -116,7 +128,10 @@ def unit_comparison(_response): return unit_comparison -def _add_match_nodes(graph, END, label, inputs, quantity_match, dimension_match, unit_comparison): +def _add_match_nodes( + graph: CriteriaGraph, END: OutputNode, label: str, inputs: Sequence[ExprNode], + quantity_match: Evaluate, dimension_match: Evaluate, unit_comparison: Evaluate, +) -> None: graph.add_evaluation_node( label, summary=f"Do the quantities {inputs[0]} and {inputs[1]} match?", @@ -239,8 +254,8 @@ def _add_match_nodes(graph, END, label, inputs, quantity_match, dimension_match, ) -def criterion_match_node(criterion, parameters, label=None): - graph = CriteriaGraph(label) +def criterion_match_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: + graph = CriteriaGraph(label) # type: ignore[arg-type] # Bug 8 in the bug report: label is None here END = graph.add_end_node() rtol, atol = _tolerances(parameters) diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index fe154a5..3bd615b 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -1,12 +1,20 @@ -from lambdafeedback.criteria import CriteriaGraph +from __future__ import annotations + +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria +from lambdafeedback.slr_parsing import ExprNode from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ...types import Parameters from ..comparison import comparison_function, parse_criterion_sides -def comparison_base_graph(criterion, parameters, comparison_operator, label=None): - graph = CriteriaGraph(label) +def comparison_base_graph( + criterion: ExprNode, parameters: Parameters, comparison_operator: str, label: str | None = None +) -> CriteriaGraph: + graph = CriteriaGraph(label) # type: ignore[arg-type] # Bug 8 in the bug report: label is None here END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() @@ -19,7 +27,7 @@ def comparison_base_graph(criterion, parameters, comparison_operator, label=None stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] compare = comparison_function(comparison_operator) - def compare_evaluate(_response): + def compare_evaluate(_response: Any) -> ReachedCriteria: return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True) graph.add_evaluation_node( @@ -51,19 +59,19 @@ def compare_evaluate(_response): return graph -def greater_than_node(criterion, parameters, label=None): +def greater_than_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: return comparison_base_graph(criterion, parameters, comparison_operator=">", label=label) -def greater_than_or_equal_node(criterion, parameters, label=None): +def greater_than_or_equal_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: # TODO: Add nodes for the equal case return comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) -def less_than_node(criterion, parameters, label=None): +def less_than_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: return comparison_base_graph(criterion, parameters, comparison_operator="<", label=label) -def less_than_or_equal_node(criterion, parameters, label=None): +def less_than_or_equal_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: # TODO: Add nodes for the equal case return comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label) diff --git a/app/context/physical_quantity/parsing.py b/app/context/physical_quantity/parsing.py index 473af11..3bdb694 100644 --- a/app/context/physical_quantity/parsing.py +++ b/app/context/physical_quantity/parsing.py @@ -1,10 +1,18 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.units import parse_quantity as parse_physical_quantity from ...feedback.physical_quantity import reverted_unit_feedback +from ..types import Parameters, QuantityDict + +if TYPE_CHECKING: + from ...result import EvaluationResult -def parse_quantity(name, expr, parameters, evaluation_result): +def parse_quantity(name: str, expr: str, parameters: Parameters, evaluation_result: EvaluationResult) -> QuantityDict: quantity = parse_physical_quantity(expr, parameters["quantity"], name) for index, reverted in enumerate(quantity.reverted_units): evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 2d16df2..7787a2f 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -1,5 +1,10 @@ +from __future__ import annotations + +from collections.abc import Iterable from copy import deepcopy +from typing import TYPE_CHECKING, Any +from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser from lambdafeedback.expression_parsing import ( ExpressionParams, SympyParsingConfig, @@ -9,22 +14,24 @@ from ...preview.symbolic import preview_function from ..defaults import base_default_parameters +from ..types import Expression, GraphTemplate, Parameters from .graphs.equivalence import criterion_equality_node from .graphs.generic import criterion_eval_node from .graphs.substitution import criterion_where_node from .graphs.syntactical_form import criterion_written_as_node -from lambdafeedback.criteria import build_criteria_parser +if TYPE_CHECKING: + from ...result import EvaluationResult default_parameters = deepcopy(base_default_parameters) -def expression_preprocess(name, expr, parameters): +def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str: return preprocess_expression(expr, ExpressionParams.from_dict(parameters), name=name) -def expression_parse(name, expr, parameters, evaluation_result): +def expression_parse(name: str, expr: str, parameters: SympyParsingConfig, evaluation_result: EvaluationResult) -> Expression: expressions = parse_expressions(expr, parameters) return expressions[0] if len(expressions) == 1 else set(expressions) @@ -32,14 +39,14 @@ def expression_parse(name, expr, parameters, evaluation_result): default_criteria = ["response = answer"] -graph_templates = { +graph_templates: dict[str, GraphTemplate] = { "EQUALITY": criterion_equality_node, "WHERE": criterion_where_node, "WRITTEN_AS": criterion_written_as_node, } -def feedback_procedure_generator(parameters_dict): +def feedback_procedure_generator(parameters_dict: Parameters) -> dict[str, CriteriaGraph]: graphs = dict() criteria = set(parameters_dict["criteria"].items()) for (label, criterion) in criteria: @@ -48,11 +55,13 @@ def feedback_procedure_generator(parameters_dict): return graphs -def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): +def parsing_parameters_generator( + params: Parameters, unsplittable_symbols: Iterable[str] = (), symbol_assumptions: Iterable[tuple[str, str]] = () +) -> SympyParsingConfig: return SympyParsingConfig.from_params(ExpressionParams.from_dict(params), unsplittable_symbols, symbol_assumptions) -context = { +context: dict[str, Any] = { "expression_preview": preview_function, "generate_criteria_parser": build_criteria_parser, "expression_preprocess": expression_preprocess, diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index ab8ba17..7d3742b 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -1,13 +1,20 @@ -from sympy import Equality, im, I +from __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence +from typing import Any + +from sympy import Basic, Equality, im, I from sympy import re as real_part -from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.expression_parsing import SympyParsingConfig, parse_expression +from lambdafeedback.slr_parsing import ExprNode from ..operators import COMPARISON_OPERATORS +from ..types import Parameters, Substitutions from .numerical import compare_numerically -def unsimplified_with_unsplittable(parsing_params, symbols): +def unsimplified_with_unsplittable(parsing_params: SympyParsingConfig, symbols: Iterable[str]) -> SympyParsingConfig: """`parsing_params` without simplification, with `symbols` also read as single symbols.""" return parsing_params.replace( simplify=False, @@ -15,16 +22,16 @@ def unsimplified_with_unsplittable(parsing_params, symbols): ) -def where_substitution_nodes(subs_node): +def where_substitution_nodes(subs_node: ExprNode) -> list[ExprNode]: """The individual substitutions in the substitution part of a WHERE criterion.""" if subs_node.label == "EQUALITY": return [subs_node] elif subs_node.label == "SEPARATOR": return subs_node.children - return subs_node + return subs_node # type: ignore[return-value] # Bug 6 in the bug report: should be a list or raise -def parse_where_substitutions(subs_node, parsing_params): +def parse_where_substitutions(subs_node: ExprNode, parsing_params: SympyParsingConfig) -> Substitutions: """`[(name, expression), ...]` for each substitution in the substitution part of a WHERE criterion.""" local_subs = [] for sub in where_substitution_nodes(subs_node): @@ -34,7 +41,7 @@ def parse_where_substitutions(subs_node, parsing_params): return local_subs -def check_criterion(criterion, parameters_dict): +def check_criterion(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: label = criterion.label.strip() if label in {"EQUALITY", "WRITTEN_AS"}: result = check_equality(criterion, parameters_dict) @@ -49,7 +56,9 @@ def check_criterion(criterion, parameters_dict): return result -def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=()): +def create_expressions_for_comparison( + criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Sequence[tuple[str, Any]] = () +) -> tuple[Basic, Basic]: reserved_expressions = list(parameters_dict["reserved_expressions"].items()) parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) lhs = criterion.children[0].content_string() @@ -65,7 +74,7 @@ def create_expressions_for_comparison(criterion, parameters_dict, local_substitu return lhs_expr, rhs_expr -def do_comparison_infinite(comparison_symbol, lhs_expr, rhs_expr): +def do_comparison_infinite(comparison_symbol: str, lhs_expr: Basic, rhs_expr: Basic) -> bool | None: # When either side is infinite, subtracting (e.g. oo - oo) yields nan, making # the subtraction-based do_comparison unreliable. Compare the sides directly instead. direct_comparisons = { @@ -81,8 +90,9 @@ def do_comparison_infinite(comparison_symbol, lhs_expr, rhs_expr): return None -def do_comparison(comparison_symbol, expression): +def do_comparison(comparison_symbol: str, expression: Basic) -> bool | None: comparison = COMPARISON_OPERATORS[comparison_symbol.strip()] + result: bool | None try: result = bool(comparison(expression.cancel().simplify().simplify(), 0)) except Exception: @@ -90,7 +100,9 @@ def do_comparison(comparison_symbol, expression): return result -def check_equality(criterion, parameters_dict, local_substitutions=()): +def check_equality( + criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Sequence[tuple[str, Any]] = () +) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality): return False @@ -110,14 +122,14 @@ def check_equality(criterion, parameters_dict, local_substitutions=()): # unpredictably when simplification is applied. For that reason we check several different combinations of # simplifications here in order to reduce the likelihood of false negatives. # Each side is simplified at most once, and only if a retry needs it - simplified = {} + simplified: dict[str, Basic] = {} - def simplified_side(side, expr): + def simplified_side(side: str, expr: Basic) -> Basic: if side not in simplified: simplified[side] = expr.simplify() return simplified[side] - differences_to_retry = ( + differences_to_retry: tuple[Callable[[], Basic], ...] = ( lambda: lhs_expr-simplified_side("rhs", rhs_expr), lambda: simplified_side("lhs", lhs_expr)-rhs_expr, lambda: simplified_side("lhs", lhs_expr)-simplified_side("rhs", rhs_expr), @@ -133,13 +145,13 @@ def simplified_side(side, expr): return result -def check_order(criterion, parameters_dict): +def check_order(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = do_comparison(criterion.content, lhs_expr-rhs_expr) return result -def check_proportionality(criterion, parameters_dict): +def check_proportionality(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = None if lhs_expr.cancel().simplify().simplify() != 0: @@ -153,7 +165,7 @@ def check_proportionality(criterion, parameters_dict): return result -def check_contains_symbol(criterion, parameters_dict): +def check_contains_symbol(criterion: ExprNode, parameters_dict: Parameters) -> bool: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = rhs_expr in lhs_expr.atoms() return result diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index 3df2cdf..8c7fdbb 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -1,16 +1,22 @@ +from __future__ import annotations + +from typing import Any + from sympy import Equality -from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.criteria import CriteriaGraph, Evaluate, OutputNode, ReachedCriteria from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.slr_parsing import ExprNode from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ...types import Parameters from ..comparison import check_equality, unsimplified_with_unsplittable from .set_equivalence import add_set_equivalence_nodes, as_sets -def _equality_equivalence(label, parameters_dict): - def equality_equivalence(_response): +def _equality_equivalence(label: str, parameters_dict: Parameters) -> Evaluate: + def equality_equivalence(_response: Any) -> ReachedCriteria: result = False res = parameters_dict["reserved_expressions"]["response"] ans = parameters_dict["reserved_expressions"]["answer"] @@ -39,8 +45,8 @@ def equality_equivalence(_response): return equality_equivalence -def _same_symbols(label, lhs, rhs, parameters_dict): - def same_symbols(_response): +def _same_symbols(label: str, lhs: str, rhs: str, parameters_dict: Parameters) -> Evaluate: + def same_symbols(_response: Any) -> ReachedCriteria: local_substitutions = list(parameters_dict["reserved_expressions"].items()) parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) @@ -50,7 +56,9 @@ def same_symbols(_response): return same_symbols -def add_equality_equivalence_nodes(graph, END, parameters_dict, label, lhs, rhs): +def add_equality_equivalence_nodes( + graph: CriteriaGraph, END: OutputNode, parameters_dict: Parameters, label: str, lhs: str, rhs: str +) -> None: """Adds the nodes that check if the equalities `lhs` and `rhs` are equivalent to `graph`.""" graph.add_evaluation_node( label, @@ -105,12 +113,14 @@ def add_equality_equivalence_nodes(graph, END, parameters_dict, label, lhs, rhs) ) -def add_mathematical_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs): +def add_mathematical_equivalence_nodes( + graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: Parameters, label: str, lhs: str, rhs: str +) -> None: """ Adds the nodes that check if `lhs` and `rhs` are mathematically equal to `graph`, and if they are, whether they have the same symbols. """ - def mathematical_equivalence(_response): + def mathematical_equivalence(_response: Any) -> ReachedCriteria: return outcome_tag(label, check_equality(criterion, parameters_dict)) graph.add_evaluation_node( @@ -171,7 +181,7 @@ def mathematical_equivalence(_response): ) -def criterion_equality_node(criterion, parameters_dict, label=None): +def criterion_equality_node(criterion: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: if label is None: label = criterion.content_string() diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index e00be16..1f91097 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -1,15 +1,21 @@ -from lambdafeedback.criteria import CriteriaGraph +from __future__ import annotations + +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria +from lambdafeedback.slr_parsing import ExprNode from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ...types import Parameters from ..comparison import check_criterion -def criterion_eval_node(criterion, parameters_dict): +def criterion_eval_node(criterion: ExprNode, parameters_dict: Parameters) -> CriteriaGraph: feedback_string_generator_inputs = {'criterion': criterion} label = criterion.content_string() - def evaluation_node_internal(_response): + def evaluation_node_internal(_response: Any) -> ReachedCriteria: result = check_criterion(criterion, parameters_dict) return outcome_tag(label, result, feedback_string_generator_inputs) diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index 67c6c2c..09e0046 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -1,11 +1,19 @@ -from sympy import Equality +from __future__ import annotations + +from typing import Any + +from sympy import Basic, Equality + +from lambdafeedback.criteria import CriteriaGraph, OutputNode, ReachedCriteria +from lambdafeedback.slr_parsing import ExprNode from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ...graph_helpers import attach_terminal_criterion +from ...types import Expression, Parameters from ..comparison import check_equality -def as_sets(response, answer): +def as_sets(response: Expression, answer: Expression) -> tuple[bool, Expression, Expression]: """ Returns whether `response` and `answer` should be compared as sets, together with `response` and `answer` wrapped in a set if only the other one is a set. @@ -19,16 +27,20 @@ def as_sets(response, answer): return False, response, answer -def add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list): +def add_set_equivalence_nodes( + graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: Parameters, label: str, lhs: str, rhs: str, + response_list: set[Basic], answer_list: set[Basic], +) -> None: """ Adds the nodes that check if each element of `response_list` is equal to an element of `answer_list` (and vice versa, depending on `multiple_answers_criteria`) to `graph`. """ - def set_equivalence(_response): + def set_equivalence(_response: Any) -> ReachedCriteria: matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} for i, response in enumerate(response_list): for j, answer in enumerate(answer_list): current_pair = [("response", response), ("answer", answer)] + result: bool | None if isinstance(response, Equality) != isinstance(answer, Equality): result = False else: diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 46b0d1e..7d320a6 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -1,13 +1,21 @@ -from lambdafeedback.criteria import CriteriaGraph +from __future__ import annotations + +from typing import Any, TypedDict + +from sympy import Basic + +from lambdafeedback.criteria import CriteriaGraph, OutputNode, ReachedCriteria from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.slr_parsing import ExprNode from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ...graph_helpers import attach_terminal_criterion, outcome_tag +from ...types import Parameters, Substitutions from ..comparison import check_equality, parse_where_substitutions, where_substitution_nodes from ..variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication -def _expression_to_vary(expression): +def _expression_to_vary(expression: ExprNode) -> ExprNode | None: """The side of `expression` opposite `response`, or None if neither side is exactly `response`.""" expression_to_vary = None if expression.children[0].content_string().strip() == "response": @@ -19,7 +27,12 @@ def _expression_to_vary(expression): return expression_to_vary -def _variation_groups(criterion, expression_to_vary): +class VariationGroup(TypedDict): + variations: list[Basic] + summary: str + + +def _variation_groups(criterion: ExprNode, expression_to_vary: Basic) -> dict[str, VariationGroup]: expression_string = criterion.children[0].content_string() return { "ONE_ADDITION_TO_SUBTRACTION": { @@ -37,11 +50,13 @@ def _variation_groups(criterion, expression_to_vary): } -def _variation_details(variations): +def _variation_details(variations: list[Basic]) -> str: return "The following expressions are checked: "+", ".join([str(e) for e in variations]) -def _group_values(expression_to_vary, variation_groups, local_subs): +def _group_values( + expression_to_vary: Basic, variation_groups: dict[str, VariationGroup], local_subs: Substitutions +) -> tuple[dict[str, set[Basic]], dict[str, set[str]]]: """ Groups `expression_to_vary` and its variations by their value after `local_subs` are applied. Returns the expressions for each value, and the variation groups that produce each value. @@ -60,10 +75,11 @@ def _group_values(expression_to_vary, variation_groups, local_subs): return values_and_expressions, values_and_variations_group -def _add_identify_reason_nodes(graph, END, label, criterion, expression, variation_groups, - values_and_expressions, values_and_variations_group, response_value): +def _add_identify_reason_nodes(graph: CriteriaGraph, END: OutputNode, label: str, criterion: ExprNode, expression: ExprNode, + variation_groups: dict[str, VariationGroup], values_and_expressions: dict[str, set[Basic]], + values_and_variations_group: dict[str, set[str]], response_value: Basic) -> None: """Adds the nodes that try to identify which variation of the expression the response matches.""" - def identify_reason(_response): + def identify_reason(_response: Any) -> ReachedCriteria: reasons = { label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) } @@ -85,7 +101,7 @@ def identify_reason(_response): feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) - def get_candidates(_response): + def get_candidates(_response: Any) -> ReachedCriteria: candidates = { label+"_RESPONSE_CANDIDATES_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] } @@ -123,7 +139,7 @@ def get_candidates(_response): ) -def criterion_where_node(criterion, parameters_dict, label=None): +def criterion_where_node(criterion: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: parsing_params = parameters_dict["parsing_parameters"] expression = criterion.children[0] subs = where_substitution_nodes(criterion.children[1]) @@ -131,7 +147,7 @@ def criterion_where_node(criterion, parameters_dict, label=None): if label is None: label = criterion.content_string() - def expression_check(_response): + def expression_check(_response: Any) -> ReachedCriteria: result = check_equality(expression, parameters_dict, local_substitutions=local_subs) return outcome_tag(label, result is True) diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index 2330711..f5f624a 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -1,13 +1,19 @@ -from lambdafeedback.criteria import CriteriaGraph +from __future__ import annotations + +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph, Evaluate, ReachedCriteria from lambdafeedback.expression_parsing import generate_arbitrary_number_pattern_matcher +from lambdafeedback.slr_parsing import ExprNode from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from ....feedback.syntactical_forms import PATTERNS as syntactical_forms from ...graph_helpers import outcome_tag +from ...types import Parameters -def written_as_known_form(string, name): - def find_form(_response): +def written_as_known_form(string: str, name: str) -> Evaluate: + def find_form(_response: Any) -> ReachedCriteria: forms = set() for form_label in syntactical_forms.keys(): if syntactical_forms[form_label].matcher(string) is True: @@ -19,20 +25,20 @@ def find_form(_response): return find_form -def compare_to_form(string, form_label, label): - def inner(_response): +def compare_to_form(string: str, form_label: str, label: str) -> Evaluate: + def inner(_response: Any) -> ReachedCriteria: return outcome_tag(label, syntactical_forms[form_label].matcher(string) is True) return inner -def response_written_as_answer(response, answer, label): - def inner(_response): +def response_written_as_answer(response: str, answer: str, label: str) -> Evaluate: + def inner(_response: Any) -> ReachedCriteria: matcher = generate_arbitrary_number_pattern_matcher(answer) return outcome_tag(label, bool(matcher(response))) return inner -def _add_known_form_nodes(graph, label, lhs, rhs, response): +def _add_known_form_nodes(graph: CriteriaGraph, label: str, lhs: str, rhs: str, response: str) -> None: """For each known form, adds nodes that check if `lhs` is written in that form when `rhs` is.""" for form_label in syntactical_forms.keys(): graph.attach_new_criterion( @@ -54,7 +60,7 @@ def _add_known_form_nodes(graph, label, lhs, rhs, response): graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") -def criterion_written_as_node(criterion, parameters_dict, label=None): +def criterion_written_as_node(criterion: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: if label is None: label = criterion.content_string() diff --git a/app/context/symbolic/numerical.py b/app/context/symbolic/numerical.py index f179d1b..1984e6e 100644 --- a/app/context/symbolic/numerical.py +++ b/app/context/symbolic/numerical.py @@ -1,7 +1,11 @@ -from sympy import pi, N +from __future__ import annotations +from sympy import Basic, pi, N -def compare_numerically(lhs_expr, rhs_expr, parameters_dict): +from ..types import Parameters + + +def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters_dict: Parameters) -> bool: """ Compares `lhs_expr` and `rhs_expr` numerically using the `atol` and `rtol` parameters. @@ -16,7 +20,7 @@ def compare_numerically(lhs_expr, rhs_expr, parameters_dict): # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. # The two lines below this comments fixes the issue but a more robust solution should be found for cases where there # are other reserved symbols. - def replace_pi(expr): + def replace_pi(expr: Basic) -> Basic: pi_symbol = pi for s in expr.free_symbols: if str(s) == 'pi': diff --git a/app/context/symbolic/variations.py b/app/context/symbolic/variations.py index 6d375ad..659c8da 100644 --- a/app/context/symbolic/variations.py +++ b/app/context/symbolic/variations.py @@ -1,10 +1,14 @@ """Variations of an expression with one operation changed, used to identify how an incorrect response was computed.""" -from sympy import Add, Pow, Mul +from __future__ import annotations +from collections.abc import Callable -def find_coords_for_node_type(expression, node_type): - stack = [(expression, tuple())] +from sympy import Add, Basic, Pow, Mul + + +def find_coords_for_node_type(expression: Basic, node_type: type[Basic]) -> list[tuple[int, ...]]: + stack: list[tuple[Basic, tuple[int, ...]]] = [(expression, tuple())] node_coords = [] while len(stack) > 0: (expr, coord) = stack.pop() @@ -15,7 +19,9 @@ def find_coords_for_node_type(expression, node_type): return node_coords -def replace_node_variations(expression, type_of_node, replacement_function): +def replace_node_variations( + expression: Basic, type_of_node: type[Basic], replacement_function: Callable[[Basic, int], Basic] +) -> list[Basic]: variations = [] list_of_coords = find_coords_for_node_type(expression, type_of_node) for coords in list_of_coords: @@ -31,19 +37,19 @@ def replace_node_variations(expression, type_of_node, replacement_function): return variations -def one_addition_to_subtraction(expression): - def addition_to_subtraction(node, k): +def one_addition_to_subtraction(expression: Basic) -> list[Basic]: + def addition_to_subtraction(node: Basic, k: int) -> Basic: return node - 2*node.args[k] variations = replace_node_variations(expression, Add, addition_to_subtraction) return variations -def one_swap_addition_and_multiplication(expression): +def one_swap_addition_and_multiplication(expression: Basic) -> list[Basic]: - def addition_to_multiplication(node, k): + def addition_to_multiplication(node: Basic, k: int) -> Basic: return node - node.args[k-1] - node.args[k] + node.args[k-1] * node.args[k] - def multiplication_to_addition(node, k): + def multiplication_to_addition(node: Basic, k: int) -> Basic: return node - 2*node.args[k] variations = replace_node_variations(expression, Add, addition_to_multiplication) @@ -52,8 +58,8 @@ def multiplication_to_addition(node, k): return variations -def one_exponent_flip(expression): - def exponent_flip(node, k): +def one_exponent_flip(expression: Basic) -> list[Basic]: + def exponent_flip(node: Basic, k: int) -> Basic: return node**(-1) variations = replace_node_variations(expression, Pow, exponent_flip) return variations diff --git a/app/context/types.py b/app/context/types.py new file mode 100644 index 0000000..243ab5c --- /dev/null +++ b/app/context/types.py @@ -0,0 +1,27 @@ +"""Type aliases shared by the contexts.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any, TypeAlias + +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.slr_parsing import ExprNode +from sympy import Basic + +# The evaluation parameters passed to the contexts. +# CONSIDER: Replace with a dataclass so that the available parameters are explicit. +Parameters: TypeAlias = Mapping[str, Any] + +# A parsed physical quantity, as returned by `physical_quantity.parsing.parse_quantity`. +# CONSIDER: Replace with a dataclass. +QuantityDict: TypeAlias = dict[str, Any] + +# A parsed response or answer: one expression, or a set of them for a list of answers. +Expression: TypeAlias = Basic | set[Basic] + +# Substitutions as passed to sympy's `subs`. +Substitutions: TypeAlias = list[tuple[str, Any]] + +# Builds the criteria graph for a criterion. +GraphTemplate: TypeAlias = Callable[[ExprNode, Parameters], CriteriaGraph] diff --git a/app/docs/dev.md b/app/docs/dev.md index e167733..ec2bacc 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -238,4 +238,7 @@ grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin # Tests The tests are in `tests/` and fall into two main groups: evaluation tests (`evaluation_test.py` and `*_evaluation_test.py`) and preview tests (`*_preview_test.py`). Run them all with `pytest` from the repository root. The base image ships its own tests in the same `tests/` directory (e.g. `commands_test.py`, `parse_test.py`), so don't reuse those file names. -The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. \ No newline at end of file +The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. + +# Type checking +All code in `app/` except the tests is type annotated and checked with mypy, using the configuration in `mypy.ini`. Run `mypy` from the repository root (CI installs `mypy==1.15.0`). Type aliases shared by the contexts, such as `Parameters` and `GraphTemplate`, are in `context/types.py`. Each `# type: ignore` comment says why it is there; most mark a place where an honest type exposes a known bug. \ No newline at end of file diff --git a/app/evaluation.py b/app/evaluation.py index 6cdded2..08f573e 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,6 +1,11 @@ +from __future__ import annotations + +from collections.abc import Collection, Iterable, Mapping from copy import deepcopy +from typing import Any from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.slr_parsing import SLRParser from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, @@ -21,14 +26,14 @@ } -def notation_error_feedback(error, original_text): +def notation_error_feedback(error: BracketNotationError | AbsoluteValueNotationError, original_text: str) -> FeedbackTag: """`FeedbackTag` for a `BracketNotationError`/`AbsoluteValueNotationError` raised while preprocessing.""" if isinstance(error, BracketNotationError): return FeedbackTag(FeedbackTagName.BRACKET_NOTATION_MISMATCH, {"x": original_text}) return FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": error.name}) -def _input_symbol_codes_and_aliases(parameters): +def _input_symbol_codes_and_aliases(parameters: Mapping[str, Any]) -> tuple[list[str], list[str]]: """The codes and (non-empty) aliases of the input symbols, from both `symbols` and the legacy `input_symbols`.""" codes = list(parameters.get("symbols", dict())) aliases = [] @@ -45,7 +50,7 @@ def _input_symbol_codes_and_aliases(parameters): return codes, aliases -def _raise_on_collisions(keywords, symbols, kind): +def _raise_on_collisions(keywords: Iterable[str], symbols: Collection[str], kind: str) -> None: """Raises if any of `keywords` is used as an input symbol `kind` (e.g. "code" or "alternative").""" collisions = sorted(keyword for keyword in keywords if keyword in symbols) if len(collisions) == 1: @@ -54,7 +59,7 @@ def _raise_on_collisions(keywords, symbols, kind): raise Exception("`"+"`, `".join(collisions)+f"` are reserved keywords and cannot be used as input symbol {kind}s.") -def determine_context(parameters): +def determine_context(parameters: Mapping[str, Any]) -> dict[str, Any]: if parameters.get("physical_quantity", False) is True: context = deepcopy(quantity_context) else: @@ -74,7 +79,9 @@ def determine_context(parameters): return context -def parse_reserved_expressions(reserved_expressions, parameters, result): +def parse_reserved_expressions( + reserved_expressions: dict[str, dict[str, str]], parameters: Mapping[str, Any], result: EvaluationResult +) -> tuple[bool, FrozenValuesDictionary]: """ Input: reserved_expressions: dictionary with the following format @@ -140,7 +147,7 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): return success, reserved_expressions_dict -def get_criteria_string(parameters): +def get_criteria_string(parameters: Mapping[str, Any]) -> str: criteria = parameters.get("criteria", None) if criteria is None: criteria = ",".join(parameters["context"]["default_criteria"]) @@ -149,7 +156,7 @@ def get_criteria_string(parameters): return criteria -def split_criteria(criteria_string): +def split_criteria(criteria_string: str) -> list[str]: """Splits `criteria_string` on the commas that are not inside brackets, and strips each criterion.""" criteria_string_list = [] delims = [ @@ -173,7 +180,7 @@ def split_criteria(criteria_string): return criteria_string_list -def create_criteria_dict(criteria_parser, parameters): +def create_criteria_dict(criteria_parser: SLRParser, parameters: Mapping[str, Any]) -> FrozenValuesDictionary: preprocess = parameters["context"]["expression_preprocess"] original_criteria_string = get_criteria_string(parameters) try: @@ -192,14 +199,14 @@ def create_criteria_dict(criteria_parser, parameters): return criteria_parsed -def _disable_evaluation_nodes(graph, disabled_evaluation_nodes): +def _disable_evaluation_nodes(graph: CriteriaGraph, disabled_evaluation_nodes: Collection[str]) -> None: """Makes each evaluation in `disabled_evaluation_nodes` skip straight to the end of `graph`.""" for evaluation in graph.evaluations.values(): if evaluation.label in disabled_evaluation_nodes: evaluation.replacement = CriteriaGraph.END_LABEL -def generate_feedback(criteria_graphs, evaluation_parameters): +def generate_feedback(criteria_graphs: Mapping[str, CriteriaGraph], evaluation_parameters: Mapping[str, Any]) -> None: # Generate feedback from criteria graphs evaluation_result = evaluation_parameters["evaluation_result"] response = evaluation_parameters["reserved_expressions"]["response"] @@ -224,7 +231,7 @@ def generate_feedback(criteria_graphs, evaluation_parameters): return -def _apply_tolerance_aliases(params): +def _apply_tolerance_aliases(params: dict[str, Any]) -> None: """Copies `relative_tolerance`/`absolute_tolerance` to `rtol`/`atol` in `params`.""" if "relative_tolerance" in params: params["rtol"] = params["relative_tolerance"] @@ -233,7 +240,7 @@ def _apply_tolerance_aliases(params): params["atol"] = params["absolute_tolerance"] -def _build_parameters(params, response, answer): +def _build_parameters(params: dict[str, Any], response: str, answer: str) -> dict[str, Any]: """ A copy of `params` completed with the context's defaults, the reserved expressions (with `response` read as LaTeX if `is_latex` is set), the context and the parsing parameters. @@ -283,7 +290,7 @@ def _build_parameters(params, response, answer): return parameters -def notation_warnings(response, strict_syntax): +def notation_warnings(response: str, strict_syntax: bool) -> list[str]: """The tags of the notation warnings for `response`.""" # FIXME: Move these notation warnings into lambdafeedback.expression_parsing tags = [] @@ -297,13 +304,15 @@ def notation_warnings(response, strict_syntax): return tags -def _add_notation_warnings(evaluation_result, response, params): +def _add_notation_warnings(evaluation_result: EvaluationResult, response: str, params: Mapping[str, Any]) -> None: symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] for tag in notation_warnings(response, params.get("strict_syntax", False)): evaluation_result.add_feedback(tag, symbolic_comparison_internal_messages(tag)(dict())) -def _set_preview_fields(evaluation_result, response, parameters, reserved_expressions): +def _set_preview_fields( + evaluation_result: EvaluationResult, response: str, parameters: Mapping[str, Any], reserved_expressions: Mapping[str, Mapping[str, Any]] +) -> None: """Sets the `latex` and `simplified` forms of the response on `evaluation_result`.""" try: preview = parameters["context"]["expression_preview"](response, deepcopy(parameters))["preview"] @@ -322,7 +331,9 @@ def _set_preview_fields(evaluation_result, response, parameters, reserved_expres evaluation_result.simplified = response -def _build_evaluation_parameters(parameters, reserved_expressions, criteria, evaluation_result): +def _build_evaluation_parameters( + parameters: Mapping[str, Any], reserved_expressions: Mapping[str, Mapping[str, Any]], criteria: Mapping[str, Any], evaluation_result: EvaluationResult +) -> FrozenValuesDictionary: return FrozenValuesDictionary( { "reserved_expressions_strings": parameters["reserved_expressions_strings"], @@ -340,7 +351,7 @@ def _build_evaluation_parameters(parameters, reserved_expressions, criteria, eva ) -def evaluation_function(response, answer, params, include_test_data=False) -> dict: +def evaluation_function(response: str, answer: str, params: dict[str, Any], include_test_data: bool = False) -> dict[str, Any]: """ Function that allows for various types of comparison of various kinds of expressions. Supported input parameters: diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py index 2ff26a5..d5eac65 100644 --- a/app/evaluation_tests.py +++ b/app/evaluation_tests.py @@ -15,7 +15,7 @@ class TestEvaluationFunction(unittest.TestCase): - def test_pytest_suite(self): + def test_pytest_suite(self) -> None: # The Lambda filesystem is read-only, so pytest's cache is disabled exit_code = pytest.main([os.path.dirname(os.path.abspath(__file__)), "-q", "-m", "not slow", "-p", "no:cacheprovider"]) self.assertEqual(exit_code, pytest.ExitCode.OK) diff --git a/app/feedback/physical_quantity.py b/app/feedback/physical_quantity.py index 905af92..bf12216 100644 --- a/app/feedback/physical_quantity.py +++ b/app/feedback/physical_quantity.py @@ -1,4 +1,11 @@ -feedback_string_generators = dict() +from __future__ import annotations + +from collections.abc import Callable + +from lambdafeedback.criteria import FeedbackStringGenerator +from lambdafeedback.units import RevertedUnit + +feedback_string_generators: dict[str, Callable[[str], FeedbackStringGenerator]] = dict() feedback_string_generators["INTERNAL"] = lambda tag: lambda inputs: { "REVERTED_UNIT": f"Possible ambiguity: `{inputs.get('marked', '')}` was not interpreted as a unit in
`{inputs['before']}``{inputs['marked']}``{inputs['after']}`", }[tag] @@ -22,7 +29,7 @@ }[tag] -def reverted_unit_feedback(reverted_unit): +def reverted_unit_feedback(reverted_unit: RevertedUnit) -> str | None: """Text for a `RevertedUnit` fact from the units package.""" return feedback_string_generators["INTERNAL"]("REVERTED_UNIT")( {"before": reverted_unit.before, "marked": reverted_unit.marked, "after": reverted_unit.after} diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 69c65f4..6b0cd61 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -1,17 +1,13 @@ -# Format for feedback string entry: criteria["eval_tag"]("criteria_tag", inputs) = "formatted string" | None -criteria_equivalences = { - **{ - eq: "response=answer" for eq in [ - "answer=response", - "answer-response=0", - "-answer+response=0", - "answer/response=1", - "response/answer-1=0" - ] - } -} -feedback_generators = dict() -feedback_generators["EQUIVALENCES"] = criteria_equivalences +from __future__ import annotations + +from collections.abc import Callable + +from lambdafeedback.criteria import FeedbackStringGenerator + +from .tags import FeedbackTag + +# Format for feedback string entry: criteria["eval_tag"]("criteria_tag")(inputs) = "formatted string" | None +feedback_generators: dict[str, Callable[[str], FeedbackStringGenerator]] = dict() feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name', '')} might be ambiguous, use `Abs(.)` instead of `|.|`", "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x', '')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", @@ -51,7 +47,6 @@ "ONE_EXPONENT_FLIP": f"{inputs['criterion'].children[0].content_string()} is true if one exponent has its sign changed.", "ONE_SWAP_ADDITION_AND_MULTIPLICATION": f"{inputs['criterion'].children[0].content_string()} is true if one addition is replaced with a multiplication or vice versa.", }[tag] -feedback_generators["GET_CANDIDATES"] = lambda tag: lambda inputs: None feedback_generators["WRITTEN_AS"] = lambda tag: lambda inputs: { "NUMBER": None, "CARTESIAN": None, @@ -67,18 +62,8 @@ "TRUE": None, "FALSE": "The response can be simplified further.", }[tag] -feedback_generators["SAME_FORM"] = lambda tag: lambda inputs: { - "CARTESIAN": "Response and answer are both written on Cartesian form.", # None, - "EXPONENTIAL": "Response and answer are both written on exponential form.", # None, - "UNKNOWN": "The response is not written on the expected form.", -}[tag] -feedback_generators["PROPORTIONAL_TO"] = lambda tag: lambda inputs: { - "TRUE": None, - "FALSE": None, - "UNKNOWN": None, -}[tag] -def internal_feedback(feedback_tag): +def internal_feedback(feedback_tag: FeedbackTag) -> str | None: """Text for a `FeedbackTag` raised by the lambdafeedback packages (e.g. `BRACKET_NOTATION_MISMATCH`).""" return feedback_generators["INTERNAL"](str(feedback_tag.tag))(feedback_tag.inputs) diff --git a/app/frozen_dict.py b/app/frozen_dict.py index d31c5d8..aa78a10 100644 --- a/app/frozen_dict.py +++ b/app/frozen_dict.py @@ -1,25 +1,29 @@ -from collections.abc import Mapping +from __future__ import annotations +from collections.abc import Iterable, Mapping +from typing import Any -class FrozenValuesDictionary(dict): + +class FrozenValuesDictionary(dict[str, Any]): """ A dictionary where new key:value pairs can be added, but changing the value for an existing key raises a TypeError """ - def __init__(self, other=None, **kwargs): + def __init__(self, other: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None, **kwargs: Any) -> None: super().__init__() self.update(other, **kwargs) - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: if key in self: msg = 'key {!r} already exists with value {!r}' raise TypeError(msg.format(key, self[key])) super().__setitem__(key, value) - def update(self, other=None, **kwargs): + # dict.update is overloaded in typeshed; this single signature accepts the same arguments + def update(self, other: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None, **kwargs: Any) -> None: # type: ignore[override] if other is not None: for k, v in other.items() if isinstance(other, Mapping) else other: self[k] = v for k, v in kwargs.items(): - self[k] = v + self[k] = v \ No newline at end of file diff --git a/app/result.py b/app/result.py index d926773..e89a72a 100644 --- a/app/result.py +++ b/app/result.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Any + from lambdafeedback.criteria import CriteriaGraph @@ -9,26 +13,26 @@ class EvaluationResult: Satisfies `lambdafeedback.criteria.ResultLike`, so `CriteriaGraph.export_feedback` can add to it. """ - def __init__(self): + def __init__(self) -> None: self.is_correct = False - self.latex = "" - self.simplified = "" - self.criteria_graphs = {} # name -> CriteriaGraph, included in test data - self._feedback = [] # (tag, text) pairs in the order they were added + self.latex: str | None = "" + self.simplified: str | list[str] | None = "" + self.criteria_graphs: dict[str, CriteriaGraph] = {} # included in test data + self._feedback: list[tuple[str, str]] = [] # (tag, text) pairs in the order they were added @property - def tags(self): + def tags(self) -> list[str]: return list(dict.fromkeys(tag for (tag, _) in self._feedback)) - def add_feedback(self, tag, feedback): + def add_feedback(self, tag: str, feedback: str | None) -> None: """Record `feedback` under `tag`. Blank feedback records the tag without adding any text.""" self._feedback.append((tag, feedback or "")) - def _serialise_feedback(self): + def _serialise_feedback(self) -> str: return "
".join(text.strip() for (_, text) in self._feedback if len(text.strip()) > 0) - def serialise(self, include_test_data=False) -> dict: - out = dict(is_correct=self.is_correct, feedback=self._serialise_feedback(), tags=self.tags) + def serialise(self, include_test_data: bool = False) -> dict[str, Any]: + out: dict[str, Any] = dict(is_correct=self.is_correct, feedback=self._serialise_feedback(), tags=self.tags) if include_test_data is True: out.update(CriteriaGraph.test_data(self.criteria_graphs)) if self.latex is not None: @@ -38,4 +42,4 @@ def serialise(self, include_test_data=False) -> dict: if isinstance(simplified, list): simplified = ", ".join(simplified) out.update(dict(response_simplified=simplified)) - return out + return out \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..770b478 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,21 @@ +[mypy] +python_version = 3.12 +files = app +exclude = ^app/tests/ +disallow_untyped_defs = True +check_untyped_defs = True +warn_unused_ignores = True +warn_redundant_casts = True + +# sympy, mpmath and latex2sympy2 ship without type information +[mypy-sympy.*] +ignore_missing_imports = True + +[mypy-mpmath.*] +ignore_missing_imports = True + +[mypy-latex2sympy2.*] +ignore_missing_imports = True + +[mypy-latex2sympy2_extended.*] +ignore_missing_imports = True \ No newline at end of file From 7d31ec2cadf0bb5c3dad59c54f3aefaef79dca3c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 12:50:51 +0100 Subject: [PATCH 13/25] Refactor evaluation graph construction: replace repetitive graph initialization logic with `outcome_graph` helper function, update imports, and streamline related modules. --- app/context/graph_helpers.py | 10 +++++++++- app/context/physical_quantity/graphs/order.py | 6 ++---- app/context/symbolic/graphs/generic.py | 6 ++---- app/context/symbolic/graphs/substitution.py | 6 ++---- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/context/graph_helpers.py b/app/context/graph_helpers.py index 662ac91..7412dc9 100644 --- a/app/context/graph_helpers.py +++ b/app/context/graph_helpers.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from typing import Any -from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator, OutputNode, ReachedCriteria +from lambdafeedback.criteria import CriteriaGraph, Evaluate, FeedbackStringGenerator, OutputNode, ReachedCriteria def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | None = None) -> ReachedCriteria: @@ -18,6 +18,14 @@ def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | Non return {label+"_UNKNOWN": inputs} +def outcome_graph(label: str, summary: str, details: str, evaluate: Evaluate) -> tuple[CriteriaGraph, OutputNode]: + """A new graph `label` with an end node and a single evaluation node `label`, ready for its outcomes to be attached.""" + graph = CriteriaGraph(label) + end = graph.add_end_node() + graph.add_evaluation_node(label, summary=summary, details=details, evaluate=evaluate) + return graph, end + + def attach_terminal_criterion( graph: CriteriaGraph, evaluation_label: str, diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index 5779267..c86b2aa 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -5,7 +5,7 @@ from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria from lambdafeedback.slr_parsing import ExprNode -from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag from app.context.physical_quantity.comparison import comparison_function, parse_criterion_sides from app.types import Parameters from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators @@ -14,8 +14,6 @@ def comparison_base_graph( criterion: ExprNode, parameters: Parameters, comparison_operator: str, label: str | None = None ) -> CriteriaGraph: - graph = CriteriaGraph(label) # type: ignore[arg-type] # Bug 8 in the bug report: label is None here - END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() if label is None: @@ -30,7 +28,7 @@ def comparison_base_graph( def compare_evaluate(_response: Any) -> ReachedCriteria: return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True) - graph.add_evaluation_node( + graph, END = outcome_graph( label, summary=f"Is {inputs[0]} {comparison_operator} {inputs[1]}?", details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index 7e4b172..99c5e2f 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -5,7 +5,7 @@ from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria from lambdafeedback.slr_parsing import ExprNode -from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag from app.context.symbolic.comparison import check_criterion from app.types import Parameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators @@ -19,9 +19,7 @@ def evaluation_node_internal(_response: Any) -> ReachedCriteria: result = check_criterion(criterion, parameters_dict) return outcome_tag(label, result, feedback_string_generator_inputs) - graph = CriteriaGraph(label) - END = graph.add_end_node() - graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) + graph, END = outcome_graph(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) attach_terminal_criterion( graph, label, diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 85d6c21..dd11cf7 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -8,7 +8,7 @@ from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.slr_parsing import ExprNode -from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag from app.context.symbolic.comparison import check_equality, parse_where_substitutions, where_substitution_nodes from app.context.symbolic.variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication from app.types import Parameters, Substitutions @@ -152,9 +152,7 @@ def expression_check(_response: Any) -> ReachedCriteria: return outcome_tag(label, result is True) subs_string = ", ".join([s.content_string() for s in subs]) - graph = CriteriaGraph(label) - END = graph.add_end_node() - graph.add_evaluation_node( + graph, END = outcome_graph( label, summary=label, details="Checks if "+expression.content_string()+" where "+subs_string+".", From 582fe46852c6d189d5c79989394aa301ac2dfbc4 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 13:09:57 +0100 Subject: [PATCH 14/25] Refactor symbolic comparison: simplify retry logic for expression comparison, eliminate redundant structures, and streamline simplification handling. --- app/context/symbolic/comparison.py | 42 ++++++++++++++---------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 72945b2..18bea4b 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Iterable from sympy import Basic, Equality, im, I from sympy import re as real_part @@ -116,32 +116,28 @@ def check_equality( result = do_comparison_infinite(criterion.content, lhs_expr, rhs_expr) else: result = do_comparison(criterion.content, lhs_expr-rhs_expr) + if result is not False: + return result # There are some types of expression, e.g. those containing hyperbolic trigonometric functions, that can behave # unpredictably when simplification is applied. For that reason we check several different combinations of # simplifications here in order to reduce the likelihood of false negatives. - # Each side is simplified at most once, and only if a retry needs it - simplified: dict[str, Basic] = {} - - def simplified_side(side: str, expr: Basic) -> Basic: - if side not in simplified: - simplified[side] = expr.simplify() - return simplified[side] - - differences_to_retry: tuple[Callable[[], Basic], ...] = ( - lambda: lhs_expr-simplified_side("rhs", rhs_expr), - lambda: simplified_side("lhs", lhs_expr)-rhs_expr, - lambda: simplified_side("lhs", lhs_expr)-simplified_side("rhs", rhs_expr), - ) - for difference in differences_to_retry: - if result is not False: - break - result = do_comparison(criterion.content, difference()) - - if result is False: - result = compare_numerically(lhs_expr, rhs_expr, parameters_dict) - - return result + # Each side is simplified only once a retry needs it. + simplified_rhs_expr = rhs_expr.simplify() + result = do_comparison(criterion.content, lhs_expr-simplified_rhs_expr) + if result is not False: + return result + + simplified_lhs_expr = lhs_expr.simplify() + result = do_comparison(criterion.content, simplified_lhs_expr-rhs_expr) + if result is not False: + return result + + result = do_comparison(criterion.content, simplified_lhs_expr-simplified_rhs_expr) + if result is not False: + return result + + return compare_numerically(lhs_expr, rhs_expr, parameters_dict) def check_order(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: From 33b3c7982321ba89612165d7d753f04e9d80fd89 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 13:25:13 +0100 Subject: [PATCH 15/25] Refactor context handling: replace context dictionaries with `Context` dataclass, introduce `base.py` for shared context logic, update evaluation methods to use `Context` object, and streamline context selection logic in `select_context`. --- app/context/base.py | 34 +++++++++++ app/context/physical_quantity/__init__.py | 24 ++++---- app/context/symbolic/__init__.py | 25 ++++---- app/docs/dev.md | 13 ++-- app/evaluation.py | 72 ++++++++++++----------- 5 files changed, 105 insertions(+), 63 deletions(-) create mode 100644 app/context/base.py diff --git a/app/context/base.py b/app/context/base.py new file mode 100644 index 0000000..3d3c1b4 --- /dev/null +++ b/app/context/base.py @@ -0,0 +1,34 @@ +"""The `Context` that specialises the evaluation function for one kind of response (symbolic or physical quantity).""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.slr_parsing import SLRParser + +from app.preview.common import Result +from app.result import EvaluationResult +from app.types import Parameters + + +@dataclass(frozen=True) +class Context: + # Previews the response, as the preview function would + expression_preview: Callable[[str, Parameters], Result] + # Builds the parser for the criteria from the parsed reserved expressions + generate_criteria_parser: Callable[[Mapping[str, Mapping[str, Any]]], SLRParser] + # `(name, expression, parameters)`: the expression string, ready to be parsed + expression_preprocess: Callable[[str, str, Parameters], str] + # `(name, expression, parsing_parameters, evaluation_result)`: the parsed expression + expression_parse: Callable[[str, str, Any, EvaluationResult], Any] + # Parameters used when they are not given + default_parameters: Mapping[str, Any] + # Criteria used when none are given + default_criteria: tuple[str, ...] + # Builds the criteria graph for each parsed criterion + feedback_procedure_generator: Callable[[Parameters], dict[str, CriteriaGraph]] + # `(parameters, unsplittable_symbols=...)`: the parsing parameters passed to `expression_parse` + parsing_parameters_generator: Callable[..., Any] diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index fe77019..de4942f 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -2,12 +2,14 @@ from collections.abc import Iterable from copy import deepcopy +from types import MappingProxyType from typing import Any from lambdafeedback.criteria import CriteriaGraph from lambdafeedback.expression_parsing import ExpressionParams, SympyParsingConfig from lambdafeedback.units import QuantityParams, preprocess_quantity +from app.context.base import Context from app.context.defaults import base_default_parameters from app.context.physical_quantity.criteria_parser import generate_criteria_parser from app.context.physical_quantity.graphs.match import criterion_match_node @@ -75,16 +77,16 @@ def parsing_parameters_generator( ) -default_criteria = ["response matches answer"] +default_criteria = ("response matches answer",) -context: dict[str, Any] = { - "expression_preview": preview_function, - "generate_criteria_parser": generate_criteria_parser, - "expression_preprocess": expression_preprocess, - "expression_parse": parse_quantity, - "default_parameters": default_parameters, - "default_criteria": default_criteria, - "feedback_procedure_generator": feedback_procedure_generator, - "parsing_parameters_generator": parsing_parameters_generator, -} +context = Context( + expression_preview=preview_function, + generate_criteria_parser=generate_criteria_parser, + expression_preprocess=expression_preprocess, + expression_parse=parse_quantity, + default_parameters=MappingProxyType(default_parameters), + default_criteria=default_criteria, + feedback_procedure_generator=feedback_procedure_generator, + parsing_parameters_generator=parsing_parameters_generator, +) diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 30dfd71..7ce9272 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Iterable from copy import deepcopy -from typing import Any +from types import MappingProxyType from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser from lambdafeedback.expression_parsing import ( @@ -12,6 +12,7 @@ preprocess_expression, ) +from app.context.base import Context from app.context.defaults import base_default_parameters from app.context.symbolic.graphs.equivalence import criterion_equality_node from app.context.symbolic.graphs.generic import criterion_eval_node @@ -34,7 +35,7 @@ def expression_parse(name: str, expr: str, parameters: SympyParsingConfig, evalu return expressions[0] if len(expressions) == 1 else set(expressions) -default_criteria = ["response = answer"] +default_criteria = ("response = answer",) graph_templates: dict[str, GraphTemplate] = { @@ -59,13 +60,13 @@ def parsing_parameters_generator( return SympyParsingConfig.from_params(ExpressionParams.from_dict(params), unsplittable_symbols, symbol_assumptions) -context: dict[str, Any] = { - "expression_preview": preview_function, - "generate_criteria_parser": build_criteria_parser, - "expression_preprocess": expression_preprocess, - "expression_parse": expression_parse, - "default_parameters": default_parameters, - "default_criteria": default_criteria, - "feedback_procedure_generator": feedback_procedure_generator, - "parsing_parameters_generator": parsing_parameters_generator, -} +context = Context( + expression_preview=preview_function, + generate_criteria_parser=build_criteria_parser, + expression_preprocess=expression_preprocess, + expression_parse=expression_parse, + default_parameters=MappingProxyType(default_parameters), + default_criteria=default_criteria, + feedback_procedure_generator=feedback_procedure_generator, + parsing_parameters_generator=parsing_parameters_generator, +) diff --git a/app/docs/dev.md b/app/docs/dev.md index 951100f..fc39862 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -47,24 +47,23 @@ The overall flow of the evaluation procedure can be described as follows: ### Context -The context is a data structure that contains at least the following seven pieces of information: -- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts start from the default parameters found in `context/defaults.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context/physical_quantity/__init__.py`. +The context is a frozen `Context` dataclass, defined in `context/base.py`, with the following fields: +- `default_parameters` A read-only mapping where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts start from the default parameters found in `context/defaults.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context/physical_quantity/__init__.py`. - `expression_parse` function that parses expressions (i.e. the `response` and `answer` inputs) into the form used by the feedback generation procedure. - `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. - `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. - `feedback_procedure_generator` function that generates a function for each criteria that can be used to evaluate if the criteria is satisfied or not. The output from this function should be a list of tags that the feedback string generator can use to produce human readable feedback. -- `feedback_string_generator` function that takes tags and outputs human readable feedback strings. - `generate_criteria_parser` function that generates a parser that can be used to turn the criteria (given in string form) into a form that the feedback generation procedure can use to determine if they are correct or not. +- `default_criteria` the criteria used when none are given in `params`. +- `parsing_parameters_generator` function that builds the parsing parameters passed to `expression_parse` from the evaluation parameters. -The context can also contain other fields if necessary. - -**Remark:** The current implementation uses a dictionary rather than a dedicated class for ease of iteration during the initial development phase. +`select_context` in `evaluation.py` picks the context from `params`. There are currently two different contexts: - `symbolic`: Handles comparisons of various symbolic expressions. Defined in the `context/symbolic/` package. - `physical_quantity`: Handles comparisons of expressions involving units. Defined in the `context/physical_quantity/` package. -Each context package exposes its `context` dictionary from its `__init__.py`. +Each context package exposes its `context` (a `Context`) from its `__init__.py`. **Remark:** Handwritten expressions are sent as latex, which requires extra preprocessing before the right context can be determined in some cases. It should be considered whether a new context, perhaps called `handwritten`, should be created for this purpose. diff --git a/app/evaluation.py b/app/evaluation.py index f9fc812..de4702b 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -13,6 +13,7 @@ parse_latex, ) +from app.context.base import Context from app.context.physical_quantity import context as quantity_context from app.context.symbolic import context as symbolic_context from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators, internal_feedback @@ -59,12 +60,18 @@ def _raise_on_collisions(keywords: Iterable[str], symbols: Collection[str], kind raise Exception("`"+"`, `".join(collisions)+f"` are reserved keywords and cannot be used as input symbol {kind}s.") -def determine_context(parameters: Parameters) -> dict[str, Any]: +def select_context(parameters: Parameters) -> Context: + """The physical quantity context if `physical_quantity` is set, otherwise the symbolic context.""" if parameters.get("physical_quantity", False) is True: - context = deepcopy(quantity_context) - else: - context = deepcopy(symbolic_context) + return quantity_context + return symbolic_context + +def _reserved_keywords(parameters: Parameters) -> list[str]: + """ + The keywords reserved by the criteria grammar. + Raises if a reserved keyword or reserved expression name is used as an input symbol. + """ input_symbols_reserved_codes, input_symbols_reserved_aliases = _input_symbol_codes_and_aliases(parameters) reserved_keywords_codes = {"where", "written as", "contains", "proportional to"} @@ -75,15 +82,15 @@ def determine_context(parameters: Parameters) -> dict[str, Any]: _raise_on_collisions(reserved_keywords_codes, input_symbols_reserved_codes, "code") _raise_on_collisions(reserved_keywords_aliases, input_symbols_reserved_aliases, "alternative") - context.update({"reserved_keywords": list(reserved_keywords_codes)}) - return context + return list(reserved_keywords_codes) def parse_reserved_expressions( - reserved_expressions: dict[str, dict[str, str]], parameters: Parameters, result: EvaluationResult + context: Context, reserved_expressions: dict[str, dict[str, str]], parameters: Parameters, result: EvaluationResult ) -> tuple[bool, FrozenValuesDictionary]: """ Input: + context: the context used to preprocess and parse the expressions reserved_expressions: dictionary with the following format { "learner": @@ -97,8 +104,8 @@ def parse_reserved_expressions( parameters: dict that contains evaluation function configuration parameters result: the EvaluationResult object that will hold feedback responses """ - parse = parameters["context"]["expression_parse"] - preprocess = parameters["context"]["expression_preprocess"] + parse = context.expression_parse + preprocess = context.expression_preprocess parsing_parameters = deepcopy(parameters["parsing_parameters"]) symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] reserved_expressions_dict = FrozenValuesDictionary() @@ -147,10 +154,10 @@ def parse_reserved_expressions( return success, reserved_expressions_dict -def get_criteria_string(parameters: Parameters) -> str: +def get_criteria_string(context: Context, parameters: Parameters) -> str: criteria = parameters.get("criteria", None) if criteria is None: - criteria = ",".join(parameters["context"]["default_criteria"]) + criteria = ",".join(context.default_criteria) if (parameters.get("syntactical_comparison", False) is True) and ("responsewrittenasanswer" not in "".join(criteria.split())): criteria = criteria+", response written as answer" return criteria @@ -180,9 +187,9 @@ def split_criteria(criteria_string: str) -> list[str]: return criteria_string_list -def create_criteria_dict(criteria_parser: SLRParser, parameters: Parameters) -> FrozenValuesDictionary: - preprocess = parameters["context"]["expression_preprocess"] - original_criteria_string = get_criteria_string(parameters) +def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameters: Parameters) -> FrozenValuesDictionary: + preprocess = context.expression_preprocess + original_criteria_string = get_criteria_string(context, parameters) try: criteria_string = preprocess("criteria", original_criteria_string, parameters) except (BracketNotationError, AbsoluteValueNotationError) as e: @@ -240,10 +247,10 @@ def _apply_tolerance_aliases(params: dict[str, Any]) -> None: params["atol"] = params["absolute_tolerance"] -def _build_parameters(params: dict[str, Any], response: str, answer: str) -> dict[str, Any]: +def _build_parameters(context: Context, params: dict[str, Any], response: str, answer: str) -> dict[str, Any]: """ A copy of `params` completed with the context's defaults, the reserved expressions - (with `response` read as LaTeX if `is_latex` is set), the context and the parsing parameters. + (with `response` read as LaTeX if `is_latex` is set), the reserved keywords and the parsing parameters. """ parameters = deepcopy(params) @@ -256,22 +263,20 @@ def _build_parameters(params: dict[str, Any], response: str, answer: str) -> dic } } parameters.update({"reserved_expressions_strings": reserved_expressions_strings}) - context = determine_context(parameters) - default_parameters = context["default_parameters"] - for (key, value) in default_parameters.items(): + reserved_keywords = _reserved_keywords(parameters) + for (key, value) in context.default_parameters.items(): if key not in parameters.keys(): parameters.update({key: value}) if "criteria" not in parameters.keys(): - parameters.update({"criteria": ",".join(context["default_criteria"])}) + parameters.update({"criteria": ",".join(context.default_criteria)}) reserved_expressions_keys = list(reserved_expressions_strings["learner"].keys())+list(reserved_expressions_strings["task"].keys()) parameters.update( { - "context": context, - "reserved_keywords": context["reserved_keywords"]+reserved_expressions_keys, + "reserved_keywords": reserved_keywords+reserved_expressions_keys, } ) - parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys) + parsing_parameters = context.parsing_parameters_generator(parameters, unsplittable_symbols=reserved_expressions_keys) parameters.update( { "parsing_parameters": parsing_parameters, @@ -279,7 +284,7 @@ def _build_parameters(params: dict[str, Any], response: str, answer: str) -> dic ) # CONSIDER: Can this be moved into the preprocessing procedures in a consistent way? - # Can it be turned into its own context? Or moved into the determine_context procedure? + # Can it be turned into its own context? Or moved into the select_context procedure? # What solution will be most consistently reusable? if parameters.get("is_latex", False): parameters["reserved_expressions_strings"]["learner"].update( @@ -311,11 +316,12 @@ def _add_notation_warnings(evaluation_result: EvaluationResult, response: str, p def _set_preview_fields( - evaluation_result: EvaluationResult, response: str, parameters: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]] + context: Context, evaluation_result: EvaluationResult, response: str, parameters: Parameters, + reserved_expressions: Mapping[str, Mapping[str, Any]] ) -> None: """Sets the `latex` and `simplified` forms of the response on `evaluation_result`.""" try: - preview = parameters["context"]["expression_preview"](response, deepcopy(parameters))["preview"] + preview = context.expression_preview(response, deepcopy(parameters))["preview"] except Exception: evaluation_result.latex = response evaluation_result.simplified = response @@ -363,26 +369,26 @@ def evaluation_function(response: str, answer: str, params: dict[str, Any], incl evaluation_result = EvaluationResult() evaluation_result.is_correct = False - parameters = _build_parameters(params, response, answer) - context = parameters["context"] + context = select_context(params) + parameters = _build_parameters(context, params, response, answer) _add_notation_warnings(evaluation_result, response, params) reserved_expressions_success, reserved_expressions = parse_reserved_expressions( - parameters["reserved_expressions_strings"], parameters, evaluation_result + context, parameters["reserved_expressions_strings"], parameters, evaluation_result ) if reserved_expressions_success is False: return evaluation_result.serialise(include_test_data) - _set_preview_fields(evaluation_result, response, parameters, reserved_expressions) + _set_preview_fields(context, evaluation_result, response, parameters, reserved_expressions) - criteria_parser = context["generate_criteria_parser"](reserved_expressions) - criteria = create_criteria_dict(criteria_parser, parameters) + criteria_parser = context.generate_criteria_parser(reserved_expressions) + criteria = create_criteria_dict(context, criteria_parser, parameters) evaluation_parameters = _build_evaluation_parameters(parameters, reserved_expressions, criteria, evaluation_result) # Performs evaluation of response - feedback_procedures = context["feedback_procedure_generator"](evaluation_parameters) + feedback_procedures = context.feedback_procedure_generator(evaluation_parameters) for graph in feedback_procedures.values(): _disable_evaluation_nodes(graph, evaluation_parameters["disabled_evaluation_nodes"]) generate_feedback(feedback_procedures, evaluation_parameters) From 4d4a8b082f52d90fdff9896f85cc0bbe7c36e682 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 13:29:04 +0100 Subject: [PATCH 16/25] Refactor quantity parsing and usage: replace `QuantityDict` with `ParsedQuantity` dataclass, update type aliases and dependent methods, and streamline parameter lookups in evaluation logic. --- app/context/physical_quantity/graphs/match.py | 26 +++++------ app/context/physical_quantity/graphs/order.py | 2 +- app/context/physical_quantity/parsing.py | 45 ++++++++++++------- app/types.py | 4 -- 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index 89ddc7c..c369c2f 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -22,7 +22,7 @@ def _tolerances(parameters: Parameters) -> tuple[float, float]: rtol = parameters["parsing_parameters"]["rtol"] atol = parameters["parsing_parameters"]["atol"] if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: - ans = parameters["reserved_expressions"]["answer"]["quantity"].value + ans = parameters["reserved_expressions"]["answer"].quantity.value if ans is not None: rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) return rtol, atol @@ -32,10 +32,10 @@ def _missing_or_unexpected(parameters: Parameters, label: str, lhs_string: str, """Tag for a response missing a value or unit that the answer has (or vice versa), otherwise None.""" # TODO: Better system for identifying if we expect the response to have value/unit or not if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string): - res_value = parameters["reserved_expressions"]["response"]["original"]["value"] - ans_value = parameters["reserved_expressions"]["answer"]["original"]["value"] - res_unit = parameters["reserved_expressions"]["response"]["original"]["unit"] - ans_unit = parameters["reserved_expressions"]["answer"]["original"]["unit"] + res_value = parameters["reserved_expressions"]["response"].original_value + ans_value = parameters["reserved_expressions"]["answer"].original_value + res_unit = parameters["reserved_expressions"]["response"].original_unit + ans_unit = parameters["reserved_expressions"]["answer"].original_unit if res_value is None and ans_value is not None: return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} if res_value is not None and ans_value is None: @@ -54,13 +54,13 @@ def _within_tolerance(parameters: Parameters, lhs_string: str, rhs_string: str, # numerical tolerances can be applied appropriately if rtol > 0 or atol > 0: if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): - ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify() - res = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify() + ans = parameters["reserved_expressions"]["answer"].standard_value.simplify() + res = parameters["reserved_expressions"]["response"].standard_value.simplify() if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): if rtol > 0 and (ans != 0): value_match = bool(abs(float((ans-res)/ans)) < rtol) elif atol > 0 or (ans == 0): - answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].unit_factor) + answer_unit_factor = float(parameters["reserved_expressions"]["answer"].quantity.unit_factor) value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) return value_match @@ -75,12 +75,12 @@ def quantity_match(_response: Any) -> ReachedCriteria: if missing_or_unexpected is not None: return missing_or_unexpected - substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions] + substitutions = [(key, expr.standard_value) for (key, expr) in reserved_expressions] value_match = is_equal(lhs, rhs, substitutions) if value_match is False: value_match = _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol) - substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] + substitutions = [(key, expr.standard_unit) for (key, expr) in reserved_expressions] unit_match = is_equal(lhs, rhs, substitutions) return outcome_tag(label, value_match is True and unit_match is True, {"lhs": lhs_string, "rhs": rhs_string}) @@ -91,7 +91,7 @@ def _dimension_match(parameters: Parameters, label: str, lhs: Basic, rhs: Basic, reserved_expressions = parameters["reserved_expressions"].items() def dimension_match(_response: Any) -> ReachedCriteria: - substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] + substitutions = [(key, expr.dimension) for (key, expr) in reserved_expressions] dimension_match, _ = is_proportional(lhs, rhs, substitutions) return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) return dimension_match @@ -101,14 +101,14 @@ def _unit_comparison(parameters: Parameters, label: str, lhs: Basic, rhs: Basic) reserved_expressions = parameters["reserved_expressions"].items() def unit_comparison(_response: Any) -> ReachedCriteria: - substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] + substitutions = [(key, expr.original_unit) for (key, expr) in reserved_expressions] if is_equal(lhs, rhs, substitutions) is True: output_tags = { label+"_UNIT_COMPARISON"+"_IDENTICAL": None } else: - local_substitutions = [(key, expr["quantity"].expanded_unit) for (key, expr) in reserved_expressions] + local_substitutions = [(key, expr.quantity.expanded_unit) for (key, expr) in reserved_expressions] result, ratio = is_proportional(lhs, rhs, local_substitutions) # `is_proportional` returns a ratio whenever the result is True if result is True and ratio is not None and ratio >= 1000: diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index c86b2aa..a2756ca 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -22,7 +22,7 @@ def comparison_base_graph( inputs = criterion.children _, _, lhs, rhs = parse_criterion_sides(criterion, parameters) - stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] + stardard_forms = [(key, expr.standard_value*expr.standard_unit) for (key, expr) in reserved_expressions] compare = comparison_function(comparison_operator) def compare_evaluate(_response: Any) -> ReachedCriteria: diff --git a/app/context/physical_quantity/parsing.py b/app/context/physical_quantity/parsing.py index ce87184..2be6c28 100644 --- a/app/context/physical_quantity/parsing.py +++ b/app/context/physical_quantity/parsing.py @@ -1,17 +1,42 @@ from __future__ import annotations +from dataclasses import dataclass + +from sympy import Basic + from lambdafeedback.expression_parsing import parse_expression -from lambdafeedback.units import parse_quantity as parse_physical_quantity +from lambdafeedback.units import PhysicalQuantity, parse_quantity as parse_physical_quantity -from app.types import Parameters, QuantityDict +from app.types import Expression, Parameters from app.feedback.physical_quantity import reverted_unit_feedback from app.result import EvaluationResult -def parse_quantity(name: str, expr: str, parameters: Parameters, evaluation_result: EvaluationResult) -> QuantityDict: +@dataclass(frozen=True) +class ParsedQuantity: + """A physical quantity together with the forms of its value and unit used by the criteria.""" + quantity: PhysicalQuantity + # The value with the unit's factor applied: one expression, a set for several values, or None if there is no value + standard_value: Expression | None + # The value and unit as written, or None if missing + original_value: Basic | None + original_unit: Basic | None + + @property + def standard_unit(self) -> Basic | None: + """The unit in SI base units, without its factor.""" + return self.quantity.standard_unit + + @property + def dimension(self) -> Basic: + return self.quantity.dimension + + +def parse_quantity(name: str, expr: str, parameters: Parameters, evaluation_result: EvaluationResult) -> ParsedQuantity: quantity = parse_physical_quantity(expr, parameters["quantity"], name) for index, reverted in enumerate(quantity.reverted_units): evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) + standard_value: Expression | None if quantity.standard_values is None: standard_value = None elif len(quantity.standard_values) == 1: @@ -20,16 +45,4 @@ def parse_quantity(name: str, expr: str, parameters: Parameters, evaluation_resu standard_value = set(quantity.standard_values) value = parse_expression(quantity.value.content_string(), quantity.parsing_config) if quantity.value is not None else None unit = parse_expression(quantity.unit.content_string(), quantity.parsing_config) if quantity.unit is not None else None - quantity_dict = { - "quantity": quantity, - "standard": { - "value": standard_value, - "unit": quantity.standard_unit - }, - "dimension": quantity.dimension, - "original": { - "value": value, - "unit": unit - }, - } - return quantity_dict + return ParsedQuantity(quantity=quantity, standard_value=standard_value, original_value=value, original_unit=unit) diff --git a/app/types.py b/app/types.py index 92859a5..958a7fb 100644 --- a/app/types.py +++ b/app/types.py @@ -13,10 +13,6 @@ # CONSIDER: Replace with a dataclass so that the available parameters are explicit. Parameters: TypeAlias = Mapping[str, Any] -# A parsed physical quantity, as returned by `physical_quantity.parsing.parse_quantity`. -# CONSIDER: Replace with a dataclass. -QuantityDict: TypeAlias = dict[str, Any] - # A parsed response or answer: one expression, or a set of them for a list of answers. Expression: TypeAlias = Basic | set[Basic] From ca2e2dfee029734dbc75bbff6d76efe45b109b2e Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 10:03:57 +0100 Subject: [PATCH 17/25] Adopt absolute imports across `app/`, replace all relative imports, and update documentation regarding import conventions. --- app/context/physical_quantity/__init__.py | 14 +++++++------- app/context/physical_quantity/comparison.py | 4 ++-- app/context/physical_quantity/graphs/match.py | 8 ++++---- app/context/physical_quantity/graphs/order.py | 8 ++++---- app/context/physical_quantity/parsing.py | 6 +++--- app/context/symbolic/__init__.py | 16 ++++++++-------- app/context/symbolic/comparison.py | 6 +++--- app/context/symbolic/graphs/equivalence.py | 10 +++++----- app/context/symbolic/graphs/generic.py | 8 ++++---- app/context/symbolic/graphs/set_equivalence.py | 8 ++++---- app/context/symbolic/graphs/substitution.py | 10 +++++----- app/context/symbolic/graphs/syntactical_form.py | 8 ++++---- app/context/symbolic/numerical.py | 2 +- app/docs/dev.md | 3 +++ app/evaluation.py | 14 +++++++------- app/feedback/symbolic.py | 2 +- app/preview/__init__.py | 6 +++--- app/preview/common.py | 2 +- app/preview/physical_quantity.py | 2 +- app/preview/symbolic.py | 4 ++-- app/tests/evaluation_test.py | 4 ++-- app/tests/example_test.py | 4 ++-- app/tests/physical_quantity_evaluation_test.py | 4 ++-- app/tests/physical_quantity_preview_test.py | 4 ++-- app/tests/symbolic_evaluation_test.py | 2 +- app/tests/symbolic_preview_test.py | 2 +- 26 files changed, 82 insertions(+), 79 deletions(-) diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index 0ce8492..ca22866 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -8,13 +8,13 @@ from lambdafeedback.expression_parsing import ExpressionParams, SympyParsingConfig from lambdafeedback.units import QuantityParams, preprocess_quantity -from ...preview.physical_quantity import preview_function -from ..defaults import base_default_parameters -from ..types import GraphTemplate, Parameters -from .criteria_parser import generate_criteria_parser -from .graphs.match import criterion_match_node -from .graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node -from .parsing import parse_quantity +from app.context.defaults import base_default_parameters +from app.context.physical_quantity.criteria_parser import generate_criteria_parser +from app.context.physical_quantity.graphs.match import criterion_match_node +from app.context.physical_quantity.graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node +from app.context.physical_quantity.parsing import parse_quantity +from app.context.types import GraphTemplate, Parameters +from app.preview.physical_quantity import preview_function def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str: diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index 231cfbb..fdec196 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -8,8 +8,8 @@ from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.slr_parsing import ExprNode -from ..operators import COMPARISON_OPERATORS -from ..types import Parameters +from app.context.operators import COMPARISON_OPERATORS +from app.context.types import Parameters # Substitutions where a value may be missing (None) OptionalSubstitutions = Iterable[tuple[str, Any]] diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index 08bb93d..591701e 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -9,10 +9,10 @@ from lambdafeedback.expression_parsing import compute_relative_tolerance_from_significant_decimals from lambdafeedback.slr_parsing import ExprNode -from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ...types import Parameters -from ..comparison import comparison_function, is_proportional, parse_criterion_sides +from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.physical_quantity.comparison import comparison_function, is_proportional, parse_criterion_sides +from app.context.types import Parameters +from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators is_equal = comparison_function("=") diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index 3bd615b..4e9a18f 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -5,10 +5,10 @@ from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria from lambdafeedback.slr_parsing import ExprNode -from ....feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ...types import Parameters -from ..comparison import comparison_function, parse_criterion_sides +from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.physical_quantity.comparison import comparison_function, parse_criterion_sides +from app.context.types import Parameters +from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators def comparison_base_graph( diff --git a/app/context/physical_quantity/parsing.py b/app/context/physical_quantity/parsing.py index 3bdb694..1df3350 100644 --- a/app/context/physical_quantity/parsing.py +++ b/app/context/physical_quantity/parsing.py @@ -5,11 +5,11 @@ from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.units import parse_quantity as parse_physical_quantity -from ...feedback.physical_quantity import reverted_unit_feedback -from ..types import Parameters, QuantityDict +from app.context.types import Parameters, QuantityDict +from app.feedback.physical_quantity import reverted_unit_feedback if TYPE_CHECKING: - from ...result import EvaluationResult + from app.result import EvaluationResult def parse_quantity(name: str, expr: str, parameters: Parameters, evaluation_result: EvaluationResult) -> QuantityDict: diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 7787a2f..04fb0b4 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -12,16 +12,16 @@ preprocess_expression, ) -from ...preview.symbolic import preview_function -from ..defaults import base_default_parameters -from ..types import Expression, GraphTemplate, Parameters -from .graphs.equivalence import criterion_equality_node -from .graphs.generic import criterion_eval_node -from .graphs.substitution import criterion_where_node -from .graphs.syntactical_form import criterion_written_as_node +from app.context.defaults import base_default_parameters +from app.context.symbolic.graphs.equivalence import criterion_equality_node +from app.context.symbolic.graphs.generic import criterion_eval_node +from app.context.symbolic.graphs.substitution import criterion_where_node +from app.context.symbolic.graphs.syntactical_form import criterion_written_as_node +from app.context.types import Expression, GraphTemplate, Parameters +from app.preview.symbolic import preview_function if TYPE_CHECKING: - from ...result import EvaluationResult + from app.result import EvaluationResult default_parameters = deepcopy(base_default_parameters) diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 7d3742b..45fa638 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -9,9 +9,9 @@ from lambdafeedback.expression_parsing import SympyParsingConfig, parse_expression from lambdafeedback.slr_parsing import ExprNode -from ..operators import COMPARISON_OPERATORS -from ..types import Parameters, Substitutions -from .numerical import compare_numerically +from app.context.operators import COMPARISON_OPERATORS +from app.context.symbolic.numerical import compare_numerically +from app.context.types import Parameters, Substitutions def unsimplified_with_unsplittable(parsing_params: SympyParsingConfig, symbols: Iterable[str]) -> SympyParsingConfig: diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index 8c7fdbb..0dc1eaf 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -8,11 +8,11 @@ from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.slr_parsing import ExprNode -from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ...types import Parameters -from ..comparison import check_equality, unsimplified_with_unsplittable -from .set_equivalence import add_set_equivalence_nodes, as_sets +from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.symbolic.comparison import check_equality, unsimplified_with_unsplittable +from app.context.symbolic.graphs.set_equivalence import add_set_equivalence_nodes, as_sets +from app.context.types import Parameters +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators def _equality_equivalence(label: str, parameters_dict: Parameters) -> Evaluate: diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index 1f91097..e4309b0 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -5,10 +5,10 @@ from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria from lambdafeedback.slr_parsing import ExprNode -from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ...types import Parameters -from ..comparison import check_criterion +from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.symbolic.comparison import check_criterion +from app.context.types import Parameters +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators def criterion_eval_node(criterion: ExprNode, parameters_dict: Parameters) -> CriteriaGraph: diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index 09e0046..09162fb 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -7,10 +7,10 @@ from lambdafeedback.criteria import CriteriaGraph, OutputNode, ReachedCriteria from lambdafeedback.slr_parsing import ExprNode -from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ...graph_helpers import attach_terminal_criterion -from ...types import Expression, Parameters -from ..comparison import check_equality +from app.context.graph_helpers import attach_terminal_criterion +from app.context.symbolic.comparison import check_equality +from app.context.types import Expression, Parameters +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators def as_sets(response: Expression, answer: Expression) -> tuple[bool, Expression, Expression]: diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 7d320a6..db1864d 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -8,11 +8,11 @@ from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.slr_parsing import ExprNode -from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ...graph_helpers import attach_terminal_criterion, outcome_tag -from ...types import Parameters, Substitutions -from ..comparison import check_equality, parse_where_substitutions, where_substitution_nodes -from ..variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication +from app.context.graph_helpers import attach_terminal_criterion, outcome_tag +from app.context.symbolic.comparison import check_equality, parse_where_substitutions, where_substitution_nodes +from app.context.symbolic.variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication +from app.context.types import Parameters, Substitutions +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators def _expression_to_vary(expression: ExprNode) -> ExprNode | None: diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index f5f624a..15f146d 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -6,10 +6,10 @@ from lambdafeedback.expression_parsing import generate_arbitrary_number_pattern_matcher from lambdafeedback.slr_parsing import ExprNode -from ....feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from ....feedback.syntactical_forms import PATTERNS as syntactical_forms -from ...graph_helpers import outcome_tag -from ...types import Parameters +from app.context.graph_helpers import outcome_tag +from app.context.types import Parameters +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from app.feedback.syntactical_forms import PATTERNS as syntactical_forms def written_as_known_form(string: str, name: str) -> Evaluate: diff --git a/app/context/symbolic/numerical.py b/app/context/symbolic/numerical.py index 1984e6e..a9c9831 100644 --- a/app/context/symbolic/numerical.py +++ b/app/context/symbolic/numerical.py @@ -2,7 +2,7 @@ from sympy import Basic, pi, N -from ..types import Parameters +from app.context.types import Parameters def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters_dict: Parameters) -> bool: diff --git a/app/docs/dev.md b/app/docs/dev.md index ec2bacc..01b732c 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -240,5 +240,8 @@ The tests are in `tests/` and fall into two main groups: evaluation tests (`eval The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. +# Imports +Modules import each other with absolute imports rooted at the `app` package (e.g. `from app.context.types import Parameters`), including the tests; relative imports are not used. This works both locally (run `pytest` and `mypy` from the repository root) and in the deployed image, which sets `PYTHONPATH=/app` and imports the function as `app.handler`. + # Type checking All code in `app/` except the tests is type annotated and checked with mypy, using the configuration in `mypy.ini`. Run `mypy` from the repository root (CI installs `mypy==1.15.0`). Type aliases shared by the contexts, such as `Parameters` and `GraphTemplate`, are in `context/types.py`. Each `# type: ignore` comment says why it is there; most mark a place where an honest type exposes a known bug. \ No newline at end of file diff --git a/app/evaluation.py b/app/evaluation.py index 08f573e..9f8f939 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -13,13 +13,13 @@ parse_latex, ) -from .result import EvaluationResult -from .context.symbolic import context as symbolic_context -from .context.physical_quantity import context as quantity_context -from .feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from .feedback.symbolic import internal_feedback -from .feedback.tags import FeedbackTag, FeedbackTagName -from .frozen_dict import FrozenValuesDictionary +from app.context.physical_quantity import context as quantity_context +from app.context.symbolic import context as symbolic_context +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from app.feedback.symbolic import internal_feedback +from app.feedback.tags import FeedbackTag, FeedbackTagName +from app.frozen_dict import FrozenValuesDictionary +from app.result import EvaluationResult messages = { "RESERVED_EXPRESSION_MISSING": lambda label: f"Reserved expression `{label}` is not defined." diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 6b0cd61..8719e7d 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -4,7 +4,7 @@ from lambdafeedback.criteria import FeedbackStringGenerator -from .tags import FeedbackTag +from app.feedback.tags import FeedbackTag # Format for feedback string entry: criteria["eval_tag"]("criteria_tag")(inputs) = "formatted string" | None feedback_generators: dict[str, Callable[[str], FeedbackStringGenerator]] = dict() diff --git a/app/preview/__init__.py b/app/preview/__init__.py index 7e9df78..5e3a87f 100644 --- a/app/preview/__init__.py +++ b/app/preview/__init__.py @@ -1,9 +1,9 @@ from collections.abc import Mapping from typing import Any -from .common import Result -from .symbolic import preview_function as symbolic_preview -from .physical_quantity import preview_function as physical_quantity_preview +from app.preview.common import Result +from app.preview.physical_quantity import preview_function as physical_quantity_preview +from app.preview.symbolic import preview_function as symbolic_preview def preview_function(response: str, params: Mapping[str, Any]) -> Result: diff --git a/app/preview/common.py b/app/preview/common.py index 756d87d..60f7549 100644 --- a/app/preview/common.py +++ b/app/preview/common.py @@ -6,7 +6,7 @@ from lambdafeedback.expression_parsing import ExpressionParsingError -from ..feedback.tags import FeedbackTag +from app.feedback.tags import FeedbackTag class ExpressionSyntaxError(ExpressionParsingError): diff --git a/app/preview/physical_quantity.py b/app/preview/physical_quantity.py index 7029cd6..6ff1e99 100644 --- a/app/preview/physical_quantity.py +++ b/app/preview/physical_quantity.py @@ -22,7 +22,7 @@ preprocess_quantity, ) -from .common import Preview, Result +from app.preview.common import Preview, Result def fix_exponents(response: str) -> str: diff --git a/app/preview/symbolic.py b/app/preview/symbolic.py index 741c1c6..b48d8d1 100644 --- a/app/preview/symbolic.py +++ b/app/preview/symbolic.py @@ -27,8 +27,8 @@ sympy_to_latex, ) -from ..feedback.tags import FeedbackTag, FeedbackTagName -from .common import ExpressionSyntaxError, Preview, Result +from app.feedback.tags import FeedbackTag, FeedbackTagName +from app.preview.common import ExpressionSyntaxError, Preview, Result def _as_params(params: ExpressionParams | Mapping[str, Any]) -> ExpressionParams: diff --git a/app/tests/evaluation_test.py b/app/tests/evaluation_test.py index 589a138..43d745d 100755 --- a/app/tests/evaluation_test.py +++ b/app/tests/evaluation_test.py @@ -1,7 +1,7 @@ import pytest -from ..preview import preview_function -from ..evaluation import evaluation_function +from app.evaluation import evaluation_function +from app.preview import preview_function class TestEvaluationFunction(): diff --git a/app/tests/example_test.py b/app/tests/example_test.py index 8cf716e..e7f0dfd 100644 --- a/app/tests/example_test.py +++ b/app/tests/example_test.py @@ -1,7 +1,7 @@ import pytest -from ..evaluation import evaluation_function -from ..preview import preview_function +from app.evaluation import evaluation_function +from app.preview import preview_function class TestEvaluationFunction(): diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index 276517a..d18b0e2 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -1,8 +1,8 @@ import pytest # Import necessary data and reference cases for tests -from .physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases -from ..evaluation import evaluation_function +from app.evaluation import evaluation_function +from app.tests.physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases from lambdafeedback.units import ( COMMON_UNITS, IMPERIAL_UNITS, diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index a23d8c3..a156b42 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -2,8 +2,8 @@ import pytest -from ..preview.physical_quantity import fix_exponents, preview_function -from .physical_quantity_test_cases import slr_natural_si_syntax_test_cases, slr_strict_si_syntax_test_cases +from app.preview.physical_quantity import fix_exponents, preview_function +from app.tests.physical_quantity_test_cases import slr_natural_si_syntax_test_cases, slr_strict_si_syntax_test_cases class TestPreviewFunction: diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 9752e91..ff1e44f 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -1,6 +1,6 @@ import pytest -from ..evaluation import evaluation_function +from app.evaluation import evaluation_function from lambdafeedback.expression_parsing import substitute from lambdafeedback.expression_parsing.symbol_tables import ELEMENTARY_FUNCTIONS as elementary_functions_names diff --git a/app/tests/symbolic_preview_test.py b/app/tests/symbolic_preview_test.py index ef47f57..bdde208 100644 --- a/app/tests/symbolic_preview_test.py +++ b/app/tests/symbolic_preview_test.py @@ -1,6 +1,6 @@ import pytest -from ..preview import preview_function +from app.preview import preview_function class TestPreviewFunction(): From a096a553aeadce265c4e83a51814ca485533b82a Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 14:51:32 +0100 Subject: [PATCH 18/25] Refactor evaluation parameters: replace `FrozenValuesDictionary` with `EvaluationParameters` dataclass, update all affected modules and type hints, streamline parameter access, and update dev documentation accordingly. --- app/context/base.py | 3 +- app/context/physical_quantity/__init__.py | 5 +- app/context/physical_quantity/comparison.py | 7 ++- app/context/physical_quantity/graphs/match.py | 44 +++++++------- app/context/physical_quantity/graphs/order.py | 14 ++--- app/context/symbolic/__init__.py | 5 +- app/context/symbolic/comparison.py | 19 +++--- app/context/symbolic/graphs/equivalence.py | 26 ++++----- app/context/symbolic/graphs/generic.py | 4 +- .../symbolic/graphs/set_equivalence.py | 13 +++-- app/context/symbolic/graphs/substitution.py | 11 ++-- .../symbolic/graphs/syntactical_form.py | 8 +-- app/context/symbolic/numerical.py | 19 +++--- app/docs/dev.md | 4 +- app/evaluation.py | 58 ++++++++++--------- app/evaluation_parameters.py | 36 ++++++++++++ app/frozen_dict.py | 20 ------- app/types.py | 8 ++- 18 files changed, 165 insertions(+), 139 deletions(-) create mode 100644 app/evaluation_parameters.py delete mode 100644 app/frozen_dict.py diff --git a/app/context/base.py b/app/context/base.py index 3d3c1b4..b1605de 100644 --- a/app/context/base.py +++ b/app/context/base.py @@ -9,6 +9,7 @@ from lambdafeedback.criteria import CriteriaGraph from lambdafeedback.slr_parsing import SLRParser +from app.evaluation_parameters import EvaluationParameters from app.preview.common import Result from app.result import EvaluationResult from app.types import Parameters @@ -29,6 +30,6 @@ class Context: # Criteria used when none are given default_criteria: tuple[str, ...] # Builds the criteria graph for each parsed criterion - feedback_procedure_generator: Callable[[Parameters], dict[str, CriteriaGraph]] + feedback_procedure_generator: Callable[[EvaluationParameters], dict[str, CriteriaGraph]] # `(parameters, unsplittable_symbols=...)`: the parsing parameters passed to `expression_parse` parsing_parameters_generator: Callable[..., Any] diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index de4942f..4a7cdec 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -15,6 +15,7 @@ from app.context.physical_quantity.graphs.match import criterion_match_node from app.context.physical_quantity.graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node from app.context.physical_quantity.parsing import parse_quantity +from app.evaluation_parameters import EvaluationParameters from app.types import GraphTemplate, Parameters from app.preview.physical_quantity import preview_function @@ -32,9 +33,9 @@ def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str: } -def feedback_procedure_generator(parameters_dict: Parameters) -> dict[str, CriteriaGraph]: +def feedback_procedure_generator(parameters_dict: EvaluationParameters) -> dict[str, CriteriaGraph]: graphs = dict() - for (label, criterion) in parameters_dict["criteria"].items(): + for (label, criterion) in parameters_dict.criteria.items(): graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) graphs.update({label: graph_template(criterion, parameters_dict)}) return graphs diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index 5f3daf5..cb8664c 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -8,14 +8,15 @@ from lambdafeedback.slr_parsing import ExprNode from app.context.operators import COMPARISON_OPERATORS -from app.types import Parameters, Substitutions +from app.evaluation_parameters import EvaluationParameters +from app.types import Substitutions NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') -def parse_criterion_sides(criterion: ExprNode, parameters: Parameters) -> tuple[str, str, Basic, Basic]: +def parse_criterion_sides(criterion: ExprNode, parameters: EvaluationParameters) -> tuple[str, str, Basic, Basic]: """The left- and right-hand sides of `criterion` as strings, and parsed without simplification.""" - parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) + parsing_params = parameters.parsing_parameters["sympy"].replace(simplify=False) lhs_string = criterion.children[0].content_string() rhs_string = criterion.children[1].content_string() return lhs_string, rhs_string, parse_expression(lhs_string, parsing_params), parse_expression(rhs_string, parsing_params) diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index c369c2f..33c4324 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -11,31 +11,31 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.physical_quantity.comparison import comparison_function, is_proportional, parse_criterion_sides -from app.types import Parameters +from app.evaluation_parameters import EvaluationParameters from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators is_equal = comparison_function("=") -def _tolerances(parameters: Parameters) -> tuple[float, float]: +def _tolerances(parameters: EvaluationParameters) -> tuple[float, float]: """`(rtol, atol)`, with `rtol` taken from the significant figures of the answer if no tolerance is given.""" - rtol = parameters["parsing_parameters"]["rtol"] - atol = parameters["parsing_parameters"]["atol"] - if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: - ans = parameters["reserved_expressions"]["answer"].quantity.value + rtol = parameters.parsing_parameters["rtol"] + atol = parameters.parsing_parameters["atol"] + if parameters.atol == 0 and parameters.rtol == 0: + ans = parameters.reserved_expressions["answer"].quantity.value if ans is not None: rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) return rtol, atol -def _missing_or_unexpected(parameters: Parameters, label: str, lhs_string: str, rhs_string: str) -> ReachedCriteria | None: +def _missing_or_unexpected(parameters: EvaluationParameters, label: str, lhs_string: str, rhs_string: str) -> ReachedCriteria | None: """Tag for a response missing a value or unit that the answer has (or vice versa), otherwise None.""" # TODO: Better system for identifying if we expect the response to have value/unit or not if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string): - res_value = parameters["reserved_expressions"]["response"].original_value - ans_value = parameters["reserved_expressions"]["answer"].original_value - res_unit = parameters["reserved_expressions"]["response"].original_unit - ans_unit = parameters["reserved_expressions"]["answer"].original_unit + res_value = parameters.reserved_expressions["response"].original_value + ans_value = parameters.reserved_expressions["answer"].original_value + res_unit = parameters.reserved_expressions["response"].original_unit + ans_unit = parameters.reserved_expressions["answer"].original_unit if res_value is None and ans_value is not None: return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} if res_value is not None and ans_value is None: @@ -47,28 +47,28 @@ def _missing_or_unexpected(parameters: Parameters, label: str, lhs_string: str, return None -def _within_tolerance(parameters: Parameters, lhs_string: str, rhs_string: str, rtol: float, atol: float) -> bool: +def _within_tolerance(parameters: EvaluationParameters, lhs_string: str, rhs_string: str, rtol: float, atol: float) -> bool: """Whether the standard values of the response and answer are within the numerical tolerances.""" value_match = False # TODO: better analysis of where `answer` is found in the criteria so that # numerical tolerances can be applied appropriately if rtol > 0 or atol > 0: if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): - ans = parameters["reserved_expressions"]["answer"].standard_value.simplify() - res = parameters["reserved_expressions"]["response"].standard_value.simplify() + ans = parameters.reserved_expressions["answer"].standard_value.simplify() + res = parameters.reserved_expressions["response"].standard_value.simplify() if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): if rtol > 0 and (ans != 0): value_match = bool(abs(float((ans-res)/ans)) < rtol) elif atol > 0 or (ans == 0): - answer_unit_factor = float(parameters["reserved_expressions"]["answer"].quantity.unit_factor) + answer_unit_factor = float(parameters.reserved_expressions["answer"].quantity.unit_factor) value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) return value_match def _quantity_match( - parameters: Parameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str, rtol: float, atol: float + parameters: EvaluationParameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str, rtol: float, atol: float ) -> Evaluate: - reserved_expressions = parameters["reserved_expressions"].items() + reserved_expressions = parameters.reserved_expressions.items() def quantity_match(_response: Any) -> ReachedCriteria: missing_or_unexpected = _missing_or_unexpected(parameters, label, lhs_string, rhs_string) @@ -87,8 +87,8 @@ def quantity_match(_response: Any) -> ReachedCriteria: return quantity_match -def _dimension_match(parameters: Parameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str) -> Evaluate: - reserved_expressions = parameters["reserved_expressions"].items() +def _dimension_match(parameters: EvaluationParameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str) -> Evaluate: + reserved_expressions = parameters.reserved_expressions.items() def dimension_match(_response: Any) -> ReachedCriteria: substitutions = [(key, expr.dimension) for (key, expr) in reserved_expressions] @@ -97,8 +97,8 @@ def dimension_match(_response: Any) -> ReachedCriteria: return dimension_match -def _unit_comparison(parameters: Parameters, label: str, lhs: Basic, rhs: Basic) -> Evaluate: - reserved_expressions = parameters["reserved_expressions"].items() +def _unit_comparison(parameters: EvaluationParameters, label: str, lhs: Basic, rhs: Basic) -> Evaluate: + reserved_expressions = parameters.reserved_expressions.items() def unit_comparison(_response: Any) -> ReachedCriteria: substitutions = [(key, expr.original_unit) for (key, expr) in reserved_expressions] @@ -254,7 +254,7 @@ def _add_match_nodes( ) -def criterion_match_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: +def criterion_match_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: graph = CriteriaGraph(label) # type: ignore[arg-type] # Bug 8 in the bug report: label is None here END = graph.add_end_node() rtol, atol = _tolerances(parameters) diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index a2756ca..fbc9df8 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -7,14 +7,14 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag from app.context.physical_quantity.comparison import comparison_function, parse_criterion_sides -from app.types import Parameters +from app.evaluation_parameters import EvaluationParameters from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators def comparison_base_graph( - criterion: ExprNode, parameters: Parameters, comparison_operator: str, label: str | None = None + criterion: ExprNode, parameters: EvaluationParameters, comparison_operator: str, label: str | None = None ) -> CriteriaGraph: - reserved_expressions = parameters["reserved_expressions"].items() + reserved_expressions = parameters.reserved_expressions.items() if label is None: label = criterion.content_string() @@ -57,19 +57,19 @@ def compare_evaluate(_response: Any) -> ReachedCriteria: return graph -def greater_than_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: +def greater_than_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: return comparison_base_graph(criterion, parameters, comparison_operator=">", label=label) -def greater_than_or_equal_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: +def greater_than_or_equal_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: # TODO: Add nodes for the equal case return comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) -def less_than_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: +def less_than_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: return comparison_base_graph(criterion, parameters, comparison_operator="<", label=label) -def less_than_or_equal_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph: +def less_than_or_equal_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: # TODO: Add nodes for the equal case return comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label) diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 7ce9272..f6761d5 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -18,6 +18,7 @@ from app.context.symbolic.graphs.generic import criterion_eval_node from app.context.symbolic.graphs.substitution import criterion_where_node from app.context.symbolic.graphs.syntactical_form import criterion_written_as_node +from app.evaluation_parameters import EvaluationParameters from app.result import EvaluationResult from app.types import Expression, GraphTemplate, Parameters from app.preview.symbolic import preview_function @@ -45,9 +46,9 @@ def expression_parse(name: str, expr: str, parameters: SympyParsingConfig, evalu } -def feedback_procedure_generator(parameters_dict: Parameters) -> dict[str, CriteriaGraph]: +def feedback_procedure_generator(parameters_dict: EvaluationParameters) -> dict[str, CriteriaGraph]: graphs = dict() - criteria = set(parameters_dict["criteria"].items()) + criteria = set(parameters_dict.criteria.items()) for (label, criterion) in criteria: graph_template = graph_templates.get(criterion.label, criterion_eval_node) graphs.update({label: graph_template(criterion, parameters_dict)}) diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 18bea4b..30850a8 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -10,7 +10,8 @@ from app.context.operators import COMPARISON_OPERATORS from app.context.symbolic.numerical import compare_numerically -from app.types import Parameters, SubstitutionList, Substitutions +from app.evaluation_parameters import EvaluationParameters +from app.types import SubstitutionList, Substitutions def unsimplified_with_unsplittable(parsing_params: SympyParsingConfig, symbols: Iterable[str]) -> SympyParsingConfig: @@ -40,7 +41,7 @@ def parse_where_substitutions(subs_node: ExprNode, parsing_params: SympyParsingC return local_subs -def check_criterion(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: +def check_criterion(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool | None: label = criterion.label.strip() if label in {"EQUALITY", "WRITTEN_AS"}: result = check_equality(criterion, parameters_dict) @@ -56,10 +57,10 @@ def check_criterion(criterion: ExprNode, parameters_dict: Parameters) -> bool | def create_expressions_for_comparison( - criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Substitutions = () + criterion: ExprNode, parameters_dict: EvaluationParameters, local_substitutions: Substitutions = () ) -> tuple[Basic, Basic]: - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) + reserved_expressions = list(parameters_dict.reserved_expressions.items()) + parsing_params = unsimplified_with_unsplittable(parameters_dict.parsing_parameters, parameters_dict.reserved_expressions.keys()) lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) @@ -100,7 +101,7 @@ def do_comparison(comparison_symbol: str, expression: Basic) -> bool | None: def check_equality( - criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Substitutions = () + criterion: ExprNode, parameters_dict: EvaluationParameters, local_substitutions: Substitutions = () ) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality): @@ -140,13 +141,13 @@ def check_equality( return compare_numerically(lhs_expr, rhs_expr, parameters_dict) -def check_order(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: +def check_order(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = do_comparison(criterion.content, lhs_expr-rhs_expr) return result -def check_proportionality(criterion: ExprNode, parameters_dict: Parameters) -> bool | None: +def check_proportionality(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = None if lhs_expr.cancel().simplify().simplify() != 0: @@ -160,7 +161,7 @@ def check_proportionality(criterion: ExprNode, parameters_dict: Parameters) -> b return result -def check_contains_symbol(criterion: ExprNode, parameters_dict: Parameters) -> bool: +def check_contains_symbol(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) result = rhs_expr in lhs_expr.atoms() return result diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index 26b0796..b2c9de3 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -11,15 +11,15 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.symbolic.comparison import check_equality, unsimplified_with_unsplittable from app.context.symbolic.graphs.set_equivalence import add_set_equivalence_nodes, as_sets -from app.types import Parameters +from app.evaluation_parameters import EvaluationParameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -def _equality_equivalence(label: str, parameters_dict: Parameters) -> Evaluate: +def _equality_equivalence(label: str, parameters_dict: EvaluationParameters) -> Evaluate: def equality_equivalence(_response: Any) -> ReachedCriteria: result = False - res = parameters_dict["reserved_expressions"]["response"] - ans = parameters_dict["reserved_expressions"]["answer"] + res = parameters_dict.reserved_expressions["response"] + ans = parameters_dict.reserved_expressions["answer"] if (not isinstance(res, Equality)) and isinstance(ans, Equality): return { @@ -40,15 +40,15 @@ def equality_equivalence(_response: Any) -> ReachedCriteria: symbols_in_equality_ratio = res_difference.free_symbols else: symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) + result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict.parsing_parameters.constants) return outcome_tag(label, result is True) return equality_equivalence -def _same_symbols(label: str, lhs: str, rhs: str, parameters_dict: Parameters) -> Evaluate: +def _same_symbols(label: str, lhs: str, rhs: str, parameters_dict: EvaluationParameters) -> Evaluate: def same_symbols(_response: Any) -> ReachedCriteria: - local_substitutions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) + local_substitutions = list(parameters_dict.reserved_expressions.items()) + parsing_params = unsimplified_with_unsplittable(parameters_dict.parsing_parameters, parameters_dict.reserved_expressions.keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols @@ -57,7 +57,7 @@ def same_symbols(_response: Any) -> ReachedCriteria: def add_equality_equivalence_nodes( - graph: CriteriaGraph, END: OutputNode, parameters_dict: Parameters, label: str, lhs: str, rhs: str + graph: CriteriaGraph, END: OutputNode, parameters_dict: EvaluationParameters, label: str, lhs: str, rhs: str ) -> None: """Adds the nodes that check if the equalities `lhs` and `rhs` are equivalent to `graph`.""" graph.add_evaluation_node( @@ -114,7 +114,7 @@ def add_equality_equivalence_nodes( def add_mathematical_equivalence_nodes( - graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: Parameters, label: str, lhs: str, rhs: str + graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: EvaluationParameters, label: str, lhs: str, rhs: str ) -> None: """ Adds the nodes that check if `lhs` and `rhs` are mathematically equal to `graph`, @@ -181,7 +181,7 @@ def mathematical_equivalence(_response: Any) -> ReachedCriteria: ) -def criterion_equality_node(criterion: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: +def criterion_equality_node(criterion: ExprNode, parameters_dict: EvaluationParameters, label: str | None = None) -> CriteriaGraph: if label is None: label = criterion.content_string() @@ -190,8 +190,8 @@ def criterion_equality_node(criterion: ExprNode, parameters_dict: Parameters, la lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() - res = parameters_dict["reserved_expressions"]["response"] - ans = parameters_dict["reserved_expressions"]["answer"] + res = parameters_dict.reserved_expressions["response"] + ans = parameters_dict.reserved_expressions["answer"] use_set_equivalence, response_list, answer_list = as_sets(res, ans) if use_set_equivalence is True: diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index 99c5e2f..f8d38e7 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -7,11 +7,11 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag from app.context.symbolic.comparison import check_criterion -from app.types import Parameters +from app.evaluation_parameters import EvaluationParameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -def criterion_eval_node(criterion: ExprNode, parameters_dict: Parameters) -> CriteriaGraph: +def criterion_eval_node(criterion: ExprNode, parameters_dict: EvaluationParameters) -> CriteriaGraph: feedback_string_generator_inputs = {'criterion': criterion} label = criterion.content_string() diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index 66d5c5d..48be7d8 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -9,7 +9,8 @@ from app.context.graph_helpers import attach_terminal_criterion from app.context.symbolic.comparison import check_equality -from app.types import Expression, Parameters +from app.evaluation_parameters import EvaluationParameters +from app.types import Expression from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators @@ -28,7 +29,7 @@ def as_sets(response: Expression, answer: Expression) -> tuple[bool, Expression, def add_set_equivalence_nodes( - graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: Parameters, label: str, lhs: str, rhs: str, + graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: EvaluationParameters, label: str, lhs: str, rhs: str, response_list: set[Basic], answer_list: set[Basic], ) -> None: """ @@ -48,26 +49,26 @@ def set_equivalence(_response: Any) -> ReachedCriteria: if result is True: matches["responses"][i] = True matches["answers"][j] = True - if parameters_dict["multiple_answers_criteria"] == "all": + if parameters_dict.multiple_answers_criteria == "all": is_correct = all(matches["responses"]) and all(matches["answers"]) if is_correct is False: return { label+"_MULTIPLE_ANSWER_FAIL_ALL": None } - elif parameters_dict["multiple_answers_criteria"] == "all_responses": + elif parameters_dict.multiple_answers_criteria == "all_responses": is_correct = all(matches["responses"]) if is_correct is False: return { label+"_MULTIPLE_ANSWER_FAIL_RESPONSE": None } - elif parameters_dict["multiple_answers_criteria"] == "all_answers": + elif parameters_dict.multiple_answers_criteria == "all_answers": is_correct = all(matches["answers"]) if is_correct is False: return { label+"_MULTIPLE_ANSWER_FAIL_ANSWER": None } else: - raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters_dict['multiple_answers_criteria']}") + raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters_dict.multiple_answers_criteria}") return { label+"_TRUE": None } diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index dd11cf7..884049b 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -11,7 +11,8 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag from app.context.symbolic.comparison import check_equality, parse_where_substitutions, where_substitution_nodes from app.context.symbolic.variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication -from app.types import Parameters, Substitutions +from app.evaluation_parameters import EvaluationParameters +from app.types import Substitutions from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators @@ -139,8 +140,8 @@ def get_candidates(_response: Any) -> ReachedCriteria: ) -def criterion_where_node(criterion: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: - parsing_params = parameters_dict["parsing_parameters"] +def criterion_where_node(criterion: ExprNode, parameters_dict: EvaluationParameters, label: str | None = None) -> CriteriaGraph: + parsing_params = parameters_dict.parsing_parameters expression = criterion.children[0] subs = where_substitution_nodes(criterion.children[1]) local_subs = parse_where_substitutions(criterion.children[1], parsing_params) @@ -179,8 +180,8 @@ def expression_check(_response: Any) -> ReachedCriteria: if expression_to_vary is None: return graph - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - response_value = parameters_dict["reserved_expressions"]["response"].subs(local_subs) + reserved_expressions = list(parameters_dict.reserved_expressions.items()) + response_value = parameters_dict.reserved_expressions["response"].subs(local_subs) expression_to_vary = parse_expression(expression_to_vary.content_string(), parsing_params).subs(reserved_expressions) variation_groups = _variation_groups(criterion, expression_to_vary) values_and_expressions, values_and_variations_group = _group_values(expression_to_vary, variation_groups, local_subs) diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index d8aae9f..b977e3c 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -7,7 +7,7 @@ from lambdafeedback.slr_parsing import ExprNode from app.context.graph_helpers import outcome_tag -from app.types import Parameters +from app.evaluation_parameters import EvaluationParameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from app.feedback.syntactical_forms import PATTERNS as syntactical_forms @@ -60,7 +60,7 @@ def _add_known_form_nodes(graph: CriteriaGraph, label: str, lhs: str, rhs: str, graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") -def criterion_written_as_node(criterion: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: +def criterion_written_as_node(criterion: ExprNode, parameters_dict: EvaluationParameters, label: str | None = None) -> CriteriaGraph: if label is None: label = criterion.content_string() @@ -74,8 +74,8 @@ def criterion_written_as_node(criterion: ExprNode, parameters_dict: Parameters, return graph # TODO: Make a version that can compare the forms of any two reserved expressions - answer = parameters_dict["reserved_expressions_strings"]["task"]["answer"] - response = parameters_dict["reserved_expressions_strings"]["learner"]["response"] + answer = parameters_dict.reserved_expressions_strings["task"]["answer"] + response = parameters_dict.reserved_expressions_strings["learner"]["response"] graph.add_evaluation_node( str(rhs)+"_WRITTEN_AS_KNOWN_FORM", diff --git a/app/context/symbolic/numerical.py b/app/context/symbolic/numerical.py index 684eb84..c15207b 100644 --- a/app/context/symbolic/numerical.py +++ b/app/context/symbolic/numerical.py @@ -2,19 +2,18 @@ from sympy import Basic, pi, N -from app.types import Parameters +from app.evaluation_parameters import EvaluationParameters - -def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters_dict: Parameters) -> bool: +def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters_dict: EvaluationParameters) -> bool: """ Compares `lhs_expr` and `rhs_expr` numerically using the `atol` and `rtol` parameters. Returns False if numerical comparison is not enabled (`numerical` is not set and neither `atol` nor `rtol` is positive), otherwise whether the difference is within both tolerances. """ - if not (parameters_dict.get("numerical", False) - or float(parameters_dict.get("rtol", 0)) > 0 - or float(parameters_dict.get("atol", 0)) > 0): + if not (parameters_dict.numerical + or float(parameters_dict.rtol) > 0 + or float(parameters_dict.atol) > 0): return False # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. @@ -30,18 +29,18 @@ def replace_pi(expr: Basic) -> Basic: # Separates LHS and RHS, parses and evaluates them res = N(replace_pi(lhs_expr)) ans = N(replace_pi(rhs_expr)) - if float(parameters_dict.get("atol", 0)) > 0: + if float(parameters_dict.atol) > 0: try: absolute_error = abs(float(ans-res)) - error_below_atol = bool(absolute_error < float(parameters_dict["atol"])) + error_below_atol = bool(absolute_error < float(parameters_dict.atol)) except TypeError: error_below_atol = None else: error_below_atol = True - if float(parameters_dict.get("rtol", 0)) > 0: + if float(parameters_dict.rtol) > 0: try: relative_error = abs(float((ans-res)/ans)) - error_below_rtol = bool(relative_error < float(parameters_dict["rtol"])) + error_below_rtol = bool(relative_error < float(parameters_dict.rtol)) except TypeError: error_below_rtol = None else: diff --git a/app/docs/dev.md b/app/docs/dev.md index fc39862..2065d31 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -39,8 +39,8 @@ The returned dictionary will be referred to as the `result` in this documentatio The overall flow of the evaluation procedure can be described as follows: 1. The function uses the parameters given in `params` to determine the context of the evaluation. What context means will be discussed in more detail in section TODO: Add section name here. -2. After the context is determined the response, answer and criteria (either supplied via `params` or from the context) are analysed an necessary information is stored for future use in a dictionary with frozen valuues, i.e. a dictionary where new items can be added but existing items cannot be changed. -3. The feedback generating procedure supplied by the context is used to generate feedback based on the contents of the frozen value dictionary. +2. After the context is determined the response, answer and criteria (either supplied via `params` or from the context) are analysed an necessary information is stored for future use in a frozen `EvaluationParameters` dataclass, defined in `evaluation_parameters.py`. +3. The feedback generating procedure supplied by the context is used to generate feedback based on the contents of the `EvaluationParameters`. 4. If all criteria are found to be satisfied the response is considered correct, i.e. the `is_correct` field in the result is set to true and the feedback string and list of tags generated by the feedback generation procedure are added to their respective fields. **TODO** Describe what further information is supplied when `include_test_data` is set to true. diff --git a/app/evaluation.py b/app/evaluation.py index de4702b..a4398df 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -5,7 +5,7 @@ from typing import Any from lambdafeedback.criteria import CriteriaGraph -from lambdafeedback.slr_parsing import SLRParser +from lambdafeedback.slr_parsing import ExprNode, SLRParser from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, @@ -18,7 +18,7 @@ from app.context.symbolic import context as symbolic_context from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators, internal_feedback from app.feedback.tags import FeedbackTag, FeedbackTagName -from app.frozen_dict import FrozenValuesDictionary +from app.evaluation_parameters import EvaluationParameters from app.result import EvaluationResult from app.types import Parameters @@ -87,7 +87,7 @@ def _reserved_keywords(parameters: Parameters) -> list[str]: def parse_reserved_expressions( context: Context, reserved_expressions: dict[str, dict[str, str]], parameters: Parameters, result: EvaluationResult -) -> tuple[bool, FrozenValuesDictionary]: +) -> tuple[bool, dict[str, dict[str, Any]]]: """ Input: context: the context used to preprocess and parse the expressions @@ -108,10 +108,10 @@ def parse_reserved_expressions( preprocess = context.expression_preprocess parsing_parameters = deepcopy(parameters["parsing_parameters"]) symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] - reserved_expressions_dict = FrozenValuesDictionary() + reserved_expressions_dict: dict[str, dict[str, Any]] = {} success = True for key in reserved_expressions.keys(): - reserved_expressions_dict.update({key: FrozenValuesDictionary()}) + reserved_expressions_dict.update({key: {}}) for (label, expr) in reserved_expressions[key].items(): expr_parsed = None original_expr = expr @@ -187,7 +187,7 @@ def split_criteria(criteria_string: str) -> list[str]: return criteria_string_list -def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameters: Parameters) -> FrozenValuesDictionary: +def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameters: Parameters) -> dict[str, ExprNode]: preprocess = context.expression_preprocess original_criteria_string = get_criteria_string(context, parameters) try: @@ -195,11 +195,14 @@ def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameter except (BracketNotationError, AbsoluteValueNotationError) as e: feedback = notation_error_feedback(e, original_criteria_string) raise Exception(internal_feedback(feedback), str(feedback.tag)) from e - criteria_parsed = FrozenValuesDictionary() + criteria_parsed: dict[str, ExprNode] = {} for criterion in split_criteria(criteria_string): try: criterion_tokens = criteria_parser.scan(criterion) criterion_parsed = criteria_parser.parse(criterion_tokens)[0] + # A criterion that is given twice cannot be parsed + if criterion_parsed.content_string() in criteria_parsed: + raise TypeError(f"criterion {criterion_parsed.content_string()!r} is given more than once") criteria_parsed.update({criterion_parsed.content_string(): criterion_parsed}) except Exception as e: raise Exception("Cannot parse criteria: `"+criterion+"`.") from e @@ -213,12 +216,12 @@ def _disable_evaluation_nodes(graph: CriteriaGraph, disabled_evaluation_nodes: C evaluation.replacement = CriteriaGraph.END_LABEL -def generate_feedback(criteria_graphs: Mapping[str, CriteriaGraph], evaluation_parameters: Parameters) -> None: +def generate_feedback(criteria_graphs: Mapping[str, CriteriaGraph], evaluation_parameters: EvaluationParameters) -> None: # Generate feedback from criteria graphs - evaluation_result = evaluation_parameters["evaluation_result"] - response = evaluation_parameters["reserved_expressions"]["response"] + evaluation_result = evaluation_parameters.evaluation_result + response = evaluation_parameters.reserved_expressions["response"] is_correct = True - custom_feedback = evaluation_parameters.get("custom_feedback", {}) + custom_feedback = evaluation_parameters.custom_feedback for (criterion_identifier, graph) in criteria_graphs.items(): # TODO: Find better way to identify main criteria for criteria graph main_criteria = criterion_identifier+"_TRUE" @@ -338,22 +341,21 @@ def _set_preview_fields( def _build_evaluation_parameters( - parameters: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]], criteria: Mapping[str, Any], evaluation_result: EvaluationResult -) -> FrozenValuesDictionary: - return FrozenValuesDictionary( - { - "reserved_expressions_strings": parameters["reserved_expressions_strings"], - "reserved_expressions": {**reserved_expressions["learner"], **reserved_expressions["task"]}, - "criteria": criteria, - "disabled_evaluation_nodes": parameters.get("disabled_evaluation_nodes", set()), - "parsing_parameters": parameters["parsing_parameters"], - "evaluation_result": evaluation_result, - "multiple_answers_criteria": parameters.get("multiple_answers_criteria", "all"), - "numerical": parameters.get("numerical", False), - "atol": parameters.get("atol", 0), - "rtol": parameters.get("rtol", 0), - "custom_feedback": parameters.get("custom_feedback", {}), - } + parameters: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]], criteria: Mapping[str, ExprNode], + evaluation_result: EvaluationResult +) -> EvaluationParameters: + return EvaluationParameters( + reserved_expressions_strings=parameters["reserved_expressions_strings"], + reserved_expressions={**reserved_expressions["learner"], **reserved_expressions["task"]}, + criteria=criteria, + disabled_evaluation_nodes=parameters.get("disabled_evaluation_nodes", set()), + parsing_parameters=parameters["parsing_parameters"], + evaluation_result=evaluation_result, + multiple_answers_criteria=parameters.get("multiple_answers_criteria", "all"), + numerical=parameters.get("numerical", False), + atol=parameters.get("atol", 0), + rtol=parameters.get("rtol", 0), + custom_feedback=parameters.get("custom_feedback", {}), ) @@ -390,7 +392,7 @@ def evaluation_function(response: str, answer: str, params: dict[str, Any], incl # Performs evaluation of response feedback_procedures = context.feedback_procedure_generator(evaluation_parameters) for graph in feedback_procedures.values(): - _disable_evaluation_nodes(graph, evaluation_parameters["disabled_evaluation_nodes"]) + _disable_evaluation_nodes(graph, evaluation_parameters.disabled_evaluation_nodes) generate_feedback(feedback_procedures, evaluation_parameters) result = evaluation_result.serialise(include_test_data) diff --git a/app/evaluation_parameters.py b/app/evaluation_parameters.py new file mode 100644 index 0000000..50e945f --- /dev/null +++ b/app/evaluation_parameters.py @@ -0,0 +1,36 @@ +"""The `EvaluationParameters` that the contexts use to build and run the criteria graphs.""" + +from __future__ import annotations + +from collections.abc import Collection, Mapping +from dataclasses import dataclass +from typing import Any + +from lambdafeedback.slr_parsing import ExprNode + +from app.result import EvaluationResult + + +@dataclass(frozen=True) +class EvaluationParameters: + # The reserved expressions as strings, by group: `{"learner": {"response": ...}, "task": {"answer": ...}}` + reserved_expressions_strings: Mapping[str, Mapping[str, str]] + # The parsed reserved expressions by name (both groups together), as returned by the context's `expression_parse` + reserved_expressions: Mapping[str, Any] + # The parsed criteria by their content string + criteria: Mapping[str, ExprNode] + # Labels of the evaluation nodes that skip straight to the end of their graph + disabled_evaluation_nodes: Collection[str] + # The parsing parameters made by the context's `parsing_parameters_generator` + parsing_parameters: Any + # Holds the feedback that is returned + evaluation_result: EvaluationResult + # "all", "all_responses" or "all_answers": which elements must match when the response or answer is a set + multiple_answers_criteria: str + # Whether to compare numerically when symbolic comparison fails + numerical: bool + # The tolerances as given in the request (0 if not given) + atol: float + rtol: float + # Custom feedback strings by tag + custom_feedback: Mapping[str, Any] diff --git a/app/frozen_dict.py b/app/frozen_dict.py deleted file mode 100644 index bfc22a6..0000000 --- a/app/frozen_dict.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from collections import UserDict -from typing import Any - - -class FrozenValuesDictionary(UserDict[str, Any]): - """ - A dictionary where new key:value pairs can be added, - but changing the value for an existing key raises - a TypeError - - `UserDict` sends every write (including `__init__` and `update`) - through `__setitem__`, so that is the only method that needs to check. - """ - def __setitem__(self, key: str, value: Any) -> None: - if key in self: - msg = 'key {!r} already exists with value {!r}' - raise TypeError(msg.format(key, self[key])) - super().__setitem__(key, value) diff --git a/app/types.py b/app/types.py index 958a7fb..191a492 100644 --- a/app/types.py +++ b/app/types.py @@ -9,8 +9,10 @@ from lambdafeedback.slr_parsing import ExprNode from sympy import Basic -# The evaluation parameters passed to the contexts. -# CONSIDER: Replace with a dataclass so that the available parameters are explicit. +from app.evaluation_parameters import EvaluationParameters + +# The request parameters, completed with the context's defaults. +# The criteria graphs use `EvaluationParameters` instead. Parameters: TypeAlias = Mapping[str, Any] # A parsed response or answer: one expression, or a set of them for a list of answers. @@ -23,7 +25,7 @@ SubstitutionList: TypeAlias = list[tuple[str, Basic]] # Builds the criteria graph for a criterion. -GraphTemplate: TypeAlias = Callable[[ExprNode, Parameters], CriteriaGraph] +GraphTemplate: TypeAlias = Callable[[ExprNode, EvaluationParameters], CriteriaGraph] # Feedback text generators by group: `generators[group](tag)(inputs)` is the text (or None). FeedbackGenerators: TypeAlias = dict[str, Callable[[str], FeedbackStringGenerator]] From 9ae968db373044974cc2b5e81f7ea2fdfce5a1b1 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 10:23:51 +0100 Subject: [PATCH 19/25] Centralize type aliases in `app/types.py`, update type hints across the codebase, refactor `requirements-dev.txt` to include dev tools, and streamline dependency management in workflows and CI. --- .github/workflows/staging-deploy.yml | 9 ++++++-- .github/workflows/test-lint.yml | 4 +--- app/context/graph_helpers.py | 4 ++-- app/context/physical_quantity/__init__.py | 2 +- app/context/physical_quantity/comparison.py | 16 +++++--------- app/context/physical_quantity/graphs/match.py | 2 +- app/context/physical_quantity/graphs/order.py | 2 +- app/context/physical_quantity/parsing.py | 8 ++----- app/context/symbolic/__init__.py | 8 +++---- app/context/symbolic/graphs/equivalence.py | 2 +- app/context/symbolic/graphs/generic.py | 2 +- .../symbolic/graphs/set_equivalence.py | 2 +- app/context/symbolic/graphs/substitution.py | 2 +- .../symbolic/graphs/syntactical_form.py | 2 +- app/context/symbolic/numerical.py | 2 +- app/docs/dev.md | 2 +- app/evaluation.py | 22 +++++++++---------- app/feedback/physical_quantity.py | 7 +++--- app/feedback/symbolic.py | 6 ++--- app/frozen_dict.py | 19 +++++----------- app/preview/__init__.py | 5 ++--- app/preview/symbolic.py | 5 ++--- app/{context => }/types.py | 16 +++++++++----- mypy.ini | 12 +--------- requirements-dev.txt | 5 +++++ 25 files changed, 73 insertions(+), 93 deletions(-) rename app/{context => }/types.py (52%) create mode 100644 requirements-dev.txt diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 4a8443b..f02d280 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -31,19 +31,24 @@ jobs: - name: Install dependencies run: | python -m venv .venv - .venv/bin/pip install pytest + .venv/bin/pip install -r requirements-dev.txt .venv/bin/pip install -r app/requirements.txt - name: Lint with flake8 run: | source .venv/bin/activate - .venv/bin/pip install flake8 # stop the build if there are Python syntax errors or undefined names flake8 ./app --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 ./app --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Type check with mypy + run: | + source .venv/bin/activate + # configuration (checked files, strictness, untyped dependencies) is in mypy.ini + mypy + - name: Run tests if: always() run: | diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml index ec896e7..c2705c5 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -28,13 +28,12 @@ jobs: - name: Install dependencies run: | python -m venv .venv - .venv/bin/pip install pytest + .venv/bin/pip install -r requirements-dev.txt .venv/bin/pip install -r app/requirements.txt - name: Lint with flake8 run: | source .venv/bin/activate - .venv/bin/pip install flake8 # stop the build if there are Python syntax errors or undefined names flake8 ./app --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide @@ -43,7 +42,6 @@ jobs: - name: Type check with mypy run: | source .venv/bin/activate - .venv/bin/pip install mypy==1.15.0 # configuration (checked files, strictness, untyped dependencies) is in mypy.ini mypy diff --git a/app/context/graph_helpers.py b/app/context/graph_helpers.py index 5241a22..662ac91 100644 --- a/app/context/graph_helpers.py +++ b/app/context/graph_helpers.py @@ -5,10 +5,10 @@ from collections.abc import Mapping from typing import Any -from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator, OutputNode +from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator, OutputNode, ReachedCriteria -def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | None = None) -> dict[str, Mapping[str, Any] | None]: +def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | None = None) -> ReachedCriteria: """`{label+"_TRUE": inputs}` if `result` is True, `label+"_FALSE"` if it is False, otherwise `label+"_UNKNOWN"`.""" if result is True: return {label+"_TRUE": inputs} diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index ca22866..fe77019 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -13,7 +13,7 @@ from app.context.physical_quantity.graphs.match import criterion_match_node from app.context.physical_quantity.graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node from app.context.physical_quantity.parsing import parse_quantity -from app.context.types import GraphTemplate, Parameters +from app.types import GraphTemplate, Parameters from app.preview.physical_quantity import preview_function diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index fdec196..5f3daf5 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -1,7 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Iterable -from typing import Any +from collections.abc import Callable from sympy import Basic, Symbol @@ -9,10 +8,7 @@ from lambdafeedback.slr_parsing import ExprNode from app.context.operators import COMPARISON_OPERATORS -from app.context.types import Parameters - -# Substitutions where a value may be missing (None) -OptionalSubstitutions = Iterable[tuple[str, Any]] +from app.types import Parameters, Substitutions NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') @@ -25,22 +21,22 @@ def parse_criterion_sides(criterion: ExprNode, parameters: Parameters) -> tuple[ return lhs_string, rhs_string, parse_expression(lhs_string, parsing_params), parse_expression(rhs_string, parsing_params) -def _substitute(lhs: Basic, rhs: Basic, substitutions: OptionalSubstitutions) -> tuple[Basic, Basic]: +def _substitute(lhs: Basic, rhs: Basic, substitutions: Substitutions) -> tuple[Basic, Basic]: """`lhs` and `rhs` with `substitutions` applied, where substitutions by None use a placeholder symbol instead.""" local_substitutions = [(key, NONE_PLACEHOLDER) if expr is None else (key, expr) for (key, expr) in substitutions] return lhs.subs(local_substitutions), rhs.subs(local_substitutions) -def comparison_function(comparison_operator: str) -> Callable[[Basic, Basic, OptionalSubstitutions], bool]: +def comparison_function(comparison_operator: str) -> Callable[[Basic, Basic, Substitutions], bool]: comparison = COMPARISON_OPERATORS[comparison_operator] - def comparison_function_inner(lhs: Basic, rhs: Basic, substitutions: OptionalSubstitutions) -> bool: + def comparison_function_inner(lhs: Basic, rhs: Basic, substitutions: Substitutions) -> bool: expr0, expr1 = _substitute(lhs, rhs, substitutions) return bool(comparison((expr0-expr1).cancel().simplify().simplify(), 0)) return comparison_function_inner -def is_proportional(lhs: Basic, rhs: Basic, substitutions: OptionalSubstitutions) -> tuple[bool | None, float | None]: +def is_proportional(lhs: Basic, rhs: Basic, substitutions: Substitutions) -> tuple[bool | None, float | None]: expr0, expr1 = _substitute(lhs, rhs, substitutions) if expr0.cancel().simplify().simplify() != 0: ratio = (expr0/expr1).cancel().simplify() diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index 591701e..89ddc7c 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -11,7 +11,7 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.physical_quantity.comparison import comparison_function, is_proportional, parse_criterion_sides -from app.context.types import Parameters +from app.types import Parameters from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators is_equal = comparison_function("=") diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index 4e9a18f..5779267 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -7,7 +7,7 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.physical_quantity.comparison import comparison_function, parse_criterion_sides -from app.context.types import Parameters +from app.types import Parameters from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators diff --git a/app/context/physical_quantity/parsing.py b/app/context/physical_quantity/parsing.py index 1df3350..ce87184 100644 --- a/app/context/physical_quantity/parsing.py +++ b/app/context/physical_quantity/parsing.py @@ -1,15 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.units import parse_quantity as parse_physical_quantity -from app.context.types import Parameters, QuantityDict +from app.types import Parameters, QuantityDict from app.feedback.physical_quantity import reverted_unit_feedback - -if TYPE_CHECKING: - from app.result import EvaluationResult +from app.result import EvaluationResult def parse_quantity(name: str, expr: str, parameters: Parameters, evaluation_result: EvaluationResult) -> QuantityDict: diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index 04fb0b4..30dfd71 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Iterable from copy import deepcopy -from typing import TYPE_CHECKING, Any +from typing import Any from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser from lambdafeedback.expression_parsing import ( @@ -17,12 +17,10 @@ from app.context.symbolic.graphs.generic import criterion_eval_node from app.context.symbolic.graphs.substitution import criterion_where_node from app.context.symbolic.graphs.syntactical_form import criterion_written_as_node -from app.context.types import Expression, GraphTemplate, Parameters +from app.result import EvaluationResult +from app.types import Expression, GraphTemplate, Parameters from app.preview.symbolic import preview_function -if TYPE_CHECKING: - from app.result import EvaluationResult - default_parameters = deepcopy(base_default_parameters) diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index 0dc1eaf..26b0796 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -11,7 +11,7 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.symbolic.comparison import check_equality, unsimplified_with_unsplittable from app.context.symbolic.graphs.set_equivalence import add_set_equivalence_nodes, as_sets -from app.context.types import Parameters +from app.types import Parameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index e4309b0..7e4b172 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -7,7 +7,7 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.symbolic.comparison import check_criterion -from app.context.types import Parameters +from app.types import Parameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index 09162fb..66d5c5d 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -9,7 +9,7 @@ from app.context.graph_helpers import attach_terminal_criterion from app.context.symbolic.comparison import check_equality -from app.context.types import Expression, Parameters +from app.types import Expression, Parameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index db1864d..85d6c21 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -11,7 +11,7 @@ from app.context.graph_helpers import attach_terminal_criterion, outcome_tag from app.context.symbolic.comparison import check_equality, parse_where_substitutions, where_substitution_nodes from app.context.symbolic.variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication -from app.context.types import Parameters, Substitutions +from app.types import Parameters, Substitutions from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index 15f146d..d8aae9f 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -7,7 +7,7 @@ from lambdafeedback.slr_parsing import ExprNode from app.context.graph_helpers import outcome_tag -from app.context.types import Parameters +from app.types import Parameters from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from app.feedback.syntactical_forms import PATTERNS as syntactical_forms diff --git a/app/context/symbolic/numerical.py b/app/context/symbolic/numerical.py index a9c9831..684eb84 100644 --- a/app/context/symbolic/numerical.py +++ b/app/context/symbolic/numerical.py @@ -2,7 +2,7 @@ from sympy import Basic, pi, N -from app.context.types import Parameters +from app.types import Parameters def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters_dict: Parameters) -> bool: diff --git a/app/docs/dev.md b/app/docs/dev.md index 01b732c..7318f14 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -244,4 +244,4 @@ The deployed function's `healthcheck` command loads `unittest` classes from `eva Modules import each other with absolute imports rooted at the `app` package (e.g. `from app.context.types import Parameters`), including the tests; relative imports are not used. This works both locally (run `pytest` and `mypy` from the repository root) and in the deployed image, which sets `PYTHONPATH=/app` and imports the function as `app.handler`. # Type checking -All code in `app/` except the tests is type annotated and checked with mypy, using the configuration in `mypy.ini`. Run `mypy` from the repository root (CI installs `mypy==1.15.0`). Type aliases shared by the contexts, such as `Parameters` and `GraphTemplate`, are in `context/types.py`. Each `# type: ignore` comment says why it is there; most mark a place where an honest type exposes a known bug. \ No newline at end of file +All code in `app/` except the tests is type annotated and checked with mypy, using the configuration in `mypy.ini`. Run `mypy` from the repository root; the tools used for testing, linting and type checking (including the pinned mypy version) are listed in `requirements-dev.txt`, which CI also installs. Shared type aliases, such as `Parameters` and `GraphTemplate`, are in `types.py`. Each `# type: ignore` comment says why it is there; most mark a place where an honest type exposes a known bug. \ No newline at end of file diff --git a/app/evaluation.py b/app/evaluation.py index 9f8f939..f9fc812 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -15,11 +15,11 @@ from app.context.physical_quantity import context as quantity_context from app.context.symbolic import context as symbolic_context -from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from app.feedback.symbolic import internal_feedback +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators, internal_feedback from app.feedback.tags import FeedbackTag, FeedbackTagName from app.frozen_dict import FrozenValuesDictionary from app.result import EvaluationResult +from app.types import Parameters messages = { "RESERVED_EXPRESSION_MISSING": lambda label: f"Reserved expression `{label}` is not defined." @@ -33,7 +33,7 @@ def notation_error_feedback(error: BracketNotationError | AbsoluteValueNotationE return FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": error.name}) -def _input_symbol_codes_and_aliases(parameters: Mapping[str, Any]) -> tuple[list[str], list[str]]: +def _input_symbol_codes_and_aliases(parameters: Parameters) -> tuple[list[str], list[str]]: """The codes and (non-empty) aliases of the input symbols, from both `symbols` and the legacy `input_symbols`.""" codes = list(parameters.get("symbols", dict())) aliases = [] @@ -59,7 +59,7 @@ def _raise_on_collisions(keywords: Iterable[str], symbols: Collection[str], kind raise Exception("`"+"`, `".join(collisions)+f"` are reserved keywords and cannot be used as input symbol {kind}s.") -def determine_context(parameters: Mapping[str, Any]) -> dict[str, Any]: +def determine_context(parameters: Parameters) -> dict[str, Any]: if parameters.get("physical_quantity", False) is True: context = deepcopy(quantity_context) else: @@ -80,7 +80,7 @@ def determine_context(parameters: Mapping[str, Any]) -> dict[str, Any]: def parse_reserved_expressions( - reserved_expressions: dict[str, dict[str, str]], parameters: Mapping[str, Any], result: EvaluationResult + reserved_expressions: dict[str, dict[str, str]], parameters: Parameters, result: EvaluationResult ) -> tuple[bool, FrozenValuesDictionary]: """ Input: @@ -147,7 +147,7 @@ def parse_reserved_expressions( return success, reserved_expressions_dict -def get_criteria_string(parameters: Mapping[str, Any]) -> str: +def get_criteria_string(parameters: Parameters) -> str: criteria = parameters.get("criteria", None) if criteria is None: criteria = ",".join(parameters["context"]["default_criteria"]) @@ -180,7 +180,7 @@ def split_criteria(criteria_string: str) -> list[str]: return criteria_string_list -def create_criteria_dict(criteria_parser: SLRParser, parameters: Mapping[str, Any]) -> FrozenValuesDictionary: +def create_criteria_dict(criteria_parser: SLRParser, parameters: Parameters) -> FrozenValuesDictionary: preprocess = parameters["context"]["expression_preprocess"] original_criteria_string = get_criteria_string(parameters) try: @@ -206,7 +206,7 @@ def _disable_evaluation_nodes(graph: CriteriaGraph, disabled_evaluation_nodes: C evaluation.replacement = CriteriaGraph.END_LABEL -def generate_feedback(criteria_graphs: Mapping[str, CriteriaGraph], evaluation_parameters: Mapping[str, Any]) -> None: +def generate_feedback(criteria_graphs: Mapping[str, CriteriaGraph], evaluation_parameters: Parameters) -> None: # Generate feedback from criteria graphs evaluation_result = evaluation_parameters["evaluation_result"] response = evaluation_parameters["reserved_expressions"]["response"] @@ -304,14 +304,14 @@ def notation_warnings(response: str, strict_syntax: bool) -> list[str]: return tags -def _add_notation_warnings(evaluation_result: EvaluationResult, response: str, params: Mapping[str, Any]) -> None: +def _add_notation_warnings(evaluation_result: EvaluationResult, response: str, params: Parameters) -> None: symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] for tag in notation_warnings(response, params.get("strict_syntax", False)): evaluation_result.add_feedback(tag, symbolic_comparison_internal_messages(tag)(dict())) def _set_preview_fields( - evaluation_result: EvaluationResult, response: str, parameters: Mapping[str, Any], reserved_expressions: Mapping[str, Mapping[str, Any]] + evaluation_result: EvaluationResult, response: str, parameters: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]] ) -> None: """Sets the `latex` and `simplified` forms of the response on `evaluation_result`.""" try: @@ -332,7 +332,7 @@ def _set_preview_fields( def _build_evaluation_parameters( - parameters: Mapping[str, Any], reserved_expressions: Mapping[str, Mapping[str, Any]], criteria: Mapping[str, Any], evaluation_result: EvaluationResult + parameters: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]], criteria: Mapping[str, Any], evaluation_result: EvaluationResult ) -> FrozenValuesDictionary: return FrozenValuesDictionary( { diff --git a/app/feedback/physical_quantity.py b/app/feedback/physical_quantity.py index bf12216..2033838 100644 --- a/app/feedback/physical_quantity.py +++ b/app/feedback/physical_quantity.py @@ -1,11 +1,10 @@ from __future__ import annotations -from collections.abc import Callable - -from lambdafeedback.criteria import FeedbackStringGenerator from lambdafeedback.units import RevertedUnit -feedback_string_generators: dict[str, Callable[[str], FeedbackStringGenerator]] = dict() +from app.types import FeedbackGenerators + +feedback_string_generators: FeedbackGenerators = dict() feedback_string_generators["INTERNAL"] = lambda tag: lambda inputs: { "REVERTED_UNIT": f"Possible ambiguity: `{inputs.get('marked', '')}` was not interpreted as a unit in
`{inputs['before']}``{inputs['marked']}``{inputs['after']}`", }[tag] diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 8719e7d..53baa24 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -1,13 +1,11 @@ from __future__ import annotations -from collections.abc import Callable - -from lambdafeedback.criteria import FeedbackStringGenerator from app.feedback.tags import FeedbackTag +from app.types import FeedbackGenerators # Format for feedback string entry: criteria["eval_tag"]("criteria_tag")(inputs) = "formatted string" | None -feedback_generators: dict[str, Callable[[str], FeedbackStringGenerator]] = dict() +feedback_generators: FeedbackGenerators = dict() feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name', '')} might be ambiguous, use `Abs(.)` instead of `|.|`", "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x', '')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", diff --git a/app/frozen_dict.py b/app/frozen_dict.py index aa78a10..bfc22a6 100644 --- a/app/frozen_dict.py +++ b/app/frozen_dict.py @@ -1,29 +1,20 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections import UserDict from typing import Any -class FrozenValuesDictionary(dict[str, Any]): +class FrozenValuesDictionary(UserDict[str, Any]): """ A dictionary where new key:value pairs can be added, but changing the value for an existing key raises a TypeError - """ - def __init__(self, other: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None, **kwargs: Any) -> None: - super().__init__() - self.update(other, **kwargs) + `UserDict` sends every write (including `__init__` and `update`) + through `__setitem__`, so that is the only method that needs to check. + """ def __setitem__(self, key: str, value: Any) -> None: if key in self: msg = 'key {!r} already exists with value {!r}' raise TypeError(msg.format(key, self[key])) super().__setitem__(key, value) - - # dict.update is overloaded in typeshed; this single signature accepts the same arguments - def update(self, other: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None, **kwargs: Any) -> None: # type: ignore[override] - if other is not None: - for k, v in other.items() if isinstance(other, Mapping) else other: - self[k] = v - for k, v in kwargs.items(): - self[k] = v \ No newline at end of file diff --git a/app/preview/__init__.py b/app/preview/__init__.py index 5e3a87f..83179fa 100644 --- a/app/preview/__init__.py +++ b/app/preview/__init__.py @@ -1,12 +1,11 @@ -from collections.abc import Mapping -from typing import Any from app.preview.common import Result from app.preview.physical_quantity import preview_function as physical_quantity_preview +from app.types import Parameters from app.preview.symbolic import preview_function as symbolic_preview -def preview_function(response: str, params: Mapping[str, Any]) -> Result: +def preview_function(response: str, params: Parameters) -> Result: """ Function used to preview a student response. --- diff --git a/app/preview/symbolic.py b/app/preview/symbolic.py index b48d8d1..39214fa 100644 --- a/app/preview/symbolic.py +++ b/app/preview/symbolic.py @@ -10,8 +10,6 @@ from collections.abc import Mapping from typing import Any -from sympy import Basic - from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, @@ -29,13 +27,14 @@ from app.feedback.tags import FeedbackTag, FeedbackTagName from app.preview.common import ExpressionSyntaxError, Preview, Result +from app.types import Expression def _as_params(params: ExpressionParams | Mapping[str, Any]) -> ExpressionParams: return params if isinstance(params, ExpressionParams) else ExpressionParams.from_dict(params) -def parse_symbolic(response: str, params: ExpressionParams) -> tuple[list[Basic | set[Basic]], list[FeedbackTag]]: +def parse_symbolic(response: str, params: ExpressionParams) -> tuple[list[Expression], list[FeedbackTag]]: """Parse a (preprocessed) response into SymPy expressions, one per expression it stands for. Also returns the feedback raised while converting ``|...|`` notation. diff --git a/app/context/types.py b/app/types.py similarity index 52% rename from app/context/types.py rename to app/types.py index 243ab5c..92859a5 100644 --- a/app/context/types.py +++ b/app/types.py @@ -1,11 +1,11 @@ -"""Type aliases shared by the contexts.""" +"""Type aliases shared across the evaluation function.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from typing import Any, TypeAlias -from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator from lambdafeedback.slr_parsing import ExprNode from sympy import Basic @@ -20,8 +20,14 @@ # A parsed response or answer: one expression, or a set of them for a list of answers. Expression: TypeAlias = Basic | set[Basic] -# Substitutions as passed to sympy's `subs`. -Substitutions: TypeAlias = list[tuple[str, Any]] +# Substitutions as passed to sympy's `subs`, as (name, value) pairs. Use for parameters. +Substitutions: TypeAlias = Iterable[tuple[str, Any]] + +# Parsed substitutions, as returned by `parse_where_substitutions`: a list, so it can be used more than once. +SubstitutionList: TypeAlias = list[tuple[str, Basic]] # Builds the criteria graph for a criterion. GraphTemplate: TypeAlias = Callable[[ExprNode, Parameters], CriteriaGraph] + +# Feedback text generators by group: `generators[group](tag)(inputs)` is the text (or None). +FeedbackGenerators: TypeAlias = dict[str, Callable[[str], FeedbackStringGenerator]] diff --git a/mypy.ini b/mypy.ini index 770b478..ba6aff9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,19 +3,9 @@ python_version = 3.12 files = app exclude = ^app/tests/ disallow_untyped_defs = True -check_untyped_defs = True warn_unused_ignores = True warn_redundant_casts = True -# sympy, mpmath and latex2sympy2 ship without type information +# sympy ships without type information [mypy-sympy.*] ignore_missing_imports = True - -[mypy-mpmath.*] -ignore_missing_imports = True - -[mypy-latex2sympy2.*] -ignore_missing_imports = True - -[mypy-latex2sympy2_extended.*] -ignore_missing_imports = True \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d32d5d1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Tools for testing, linting and type checking, used locally and in CI +# (the deployed function's own dependencies are in app/requirements.txt) +pytest +flake8 +mypy==1.15.0 From 294d2fe68eee2d1e8293155f2dc07a165694b7f7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 14:55:59 +0100 Subject: [PATCH 20/25] Refactor error reporting in `parse_reserved_expressions`: introduce `_report` helper to centralize feedback and exception handling logic, reduce redundancy, and streamline reserved expression evaluation. --- app/evaluation.py | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index a4398df..5f7af5f 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -85,6 +85,21 @@ def _reserved_keywords(parameters: Parameters) -> list[str]: return list(reserved_keywords_codes) +def _report( + key: str, result: EvaluationResult, tag: str, message: str | None, error: Exception, cause: Exception | None = None +) -> None: + """ + Reports a problem with a reserved expression: for a learner expression it is added to `result` as feedback, + for a task expression it is an error in the task, so `error` is raised (from `cause`). + """ + if key == "learner": + result.add_feedback(tag, message) + elif cause is None: + raise error + else: + raise error from cause + + def parse_reserved_expressions( context: Context, reserved_expressions: dict[str, dict[str, str]], parameters: Parameters, result: EvaluationResult ) -> tuple[bool, dict[str, dict[str, Any]]]: @@ -120,22 +135,17 @@ def parse_reserved_expressions( except (BracketNotationError, AbsoluteValueNotationError) as e: feedback = notation_error_feedback(e, original_expr) expr = e.expression - if key == "learner": - # Preprocess can only create warnings for responses in this case - result.add_feedback(str(feedback.tag), internal_feedback(feedback)) - else: - raise Exception(internal_feedback(feedback), str(feedback.tag)) from e + # Preprocess can only create warnings for responses in this case + tag = str(feedback.tag) + message = internal_feedback(feedback) + _report(key, result, tag, message, Exception(message, tag), cause=e) reserved_expressions[key][label] = expr if not isinstance(expr, str): raise Exception(f"Reserved expression {label} must be given as a string.") if len(expr.strip()) == 0: - if key == "learner": - result.add_feedback( - f"RESERVED_EXPRESSION_MISSING_{label}", - messages["RESERVED_EXPRESSION_MISSING"](label) - ) - else: - raise Exception(messages["RESERVED_EXPRESSION_MISSING"](label), f"RESERVED_EXPRESSION_MISSING_{label}") + tag = f"RESERVED_EXPRESSION_MISSING_{label}" + message = messages["RESERVED_EXPRESSION_MISSING"](label) + _report(key, result, tag, message, Exception(message, tag)) success = False else: try: @@ -143,13 +153,9 @@ def parse_reserved_expressions( except Exception as e: result.is_correct = False success = False - if key == "learner": - result.add_feedback( - f"PARSE_ERROR_{label}", - symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) - ) - else: - raise Exception(symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})) from e + message = symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) + # The error for a task expression has no tag + _report(key, result, f"PARSE_ERROR_{label}", message, Exception(message), cause=e) reserved_expressions_dict[key].update({label: expr_parsed}) return success, reserved_expressions_dict From 81f3a7e876af9c8abc49e6c80fdbf54a4e44d3e4 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 10:23:59 +0100 Subject: [PATCH 21/25] Update type aliases in `comparison.py` to use centralized definitions from `app/types.py`, remove unused imports, and refine function type hints. --- app/context/symbolic/comparison.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 45fa638..72945b2 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -1,7 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Sequence -from typing import Any +from collections.abc import Callable, Iterable from sympy import Basic, Equality, im, I from sympy import re as real_part @@ -11,7 +10,7 @@ from app.context.operators import COMPARISON_OPERATORS from app.context.symbolic.numerical import compare_numerically -from app.context.types import Parameters, Substitutions +from app.types import Parameters, SubstitutionList, Substitutions def unsimplified_with_unsplittable(parsing_params: SympyParsingConfig, symbols: Iterable[str]) -> SympyParsingConfig: @@ -31,7 +30,7 @@ def where_substitution_nodes(subs_node: ExprNode) -> list[ExprNode]: return subs_node # type: ignore[return-value] # Bug 6 in the bug report: should be a list or raise -def parse_where_substitutions(subs_node: ExprNode, parsing_params: SympyParsingConfig) -> Substitutions: +def parse_where_substitutions(subs_node: ExprNode, parsing_params: SympyParsingConfig) -> SubstitutionList: """`[(name, expression), ...]` for each substitution in the substitution part of a WHERE criterion.""" local_subs = [] for sub in where_substitution_nodes(subs_node): @@ -57,7 +56,7 @@ def check_criterion(criterion: ExprNode, parameters_dict: Parameters) -> bool | def create_expressions_for_comparison( - criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Sequence[tuple[str, Any]] = () + criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Substitutions = () ) -> tuple[Basic, Basic]: reserved_expressions = list(parameters_dict["reserved_expressions"].items()) parsing_params = unsimplified_with_unsplittable(parameters_dict["parsing_parameters"], parameters_dict["reserved_expressions"].keys()) @@ -101,7 +100,7 @@ def do_comparison(comparison_symbol: str, expression: Basic) -> bool | None: def check_equality( - criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Sequence[tuple[str, Any]] = () + criterion: ExprNode, parameters_dict: Parameters, local_substitutions: Substitutions = () ) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality): From a14026f0439218ce389477ae4512e8d1aa7483f5 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 19 Oct 2026 19:30:50 +0100 Subject: [PATCH 22/25] Update lambdafeedback packages to v0.3.0-rc.3 and adopt helpers moved into them Use `split_criteria`, `build_quantity_criteria_parser` and `where_substitutions` from `lambdafeedback.criteria`, and `notation_warnings` from `lambdafeedback.expression_parsing`, in place of the local copies. Empty criteria in a criteria list now raise "Cannot parse criteria". Add tests for malformed criteria lists and update dev documentation. Co-Authored-By: Claude Opus 5.5 --- app/context/physical_quantity/__init__.py | 5 +- .../physical_quantity/criteria_parser.py | 52 ------------------- app/context/symbolic/comparison.py | 17 +----- app/context/symbolic/graphs/substitution.py | 8 +-- app/docs/dev.md | 4 +- app/evaluation.py | 50 ++++-------------- app/requirements.txt | 10 ++-- app/tests/symbolic_evaluation_test.py | 17 ++++++ 8 files changed, 41 insertions(+), 122 deletions(-) delete mode 100644 app/context/physical_quantity/criteria_parser.py diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index af35556..d7ba636 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -5,13 +5,12 @@ from types import MappingProxyType from typing import Any -from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.criteria import CriteriaGraph, build_quantity_criteria_parser from lambdafeedback.expression_parsing import ExpressionParams, SympyParsingConfig from lambdafeedback.units import QuantityParams, preprocess_quantity from app.context.base import Context from app.context.defaults import base_default_parameters -from app.context.physical_quantity.criteria_parser import generate_criteria_parser from app.context.physical_quantity.graphs.match import criterion_match_node from app.context.physical_quantity.graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node from app.context.physical_quantity.parsing import parse_quantity @@ -83,7 +82,7 @@ def parsing_parameters_generator( context = Context( expression_preview=preview_function, - generate_criteria_parser=generate_criteria_parser, + generate_criteria_parser=build_quantity_criteria_parser, expression_preprocess=expression_preprocess, expression_parse=parse_quantity, default_parameters=MappingProxyType(default_parameters), diff --git a/app/context/physical_quantity/criteria_parser.py b/app/context/physical_quantity/criteria_parser.py deleted file mode 100644 index 677bce4..0000000 --- a/app/context/physical_quantity/criteria_parser.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate - - -def generate_criteria_parser(reserved_expressions: Mapping[str, Any]) -> SLRParser: - start_symbol = "START" - end_symbol = "END" - null_symbol = "NULL" - - criteria_operations = { - "matches", - "dimension", - "=", - "<=", - ">=", - "<", - ">", - } - - token_list: list[TokenPattern | CatchAll] = [ - TokenPattern(start_symbol, start_symbol), - TokenPattern(end_symbol, end_symbol), - TokenPattern(null_symbol, null_symbol), - TokenPattern(Lexeme(("BOOL",), space_before=True, space_after=True), "BOOL"), - TokenPattern(Lexeme(("QUANTITY",), space_before=True, space_after=True), "QUANTITY"), - TokenPattern(Lexeme(("DIMENSION",), space_before=True, space_after=True), "DIMENSION"), - TokenPattern(Lexeme(("(",), space_after=True), "START_DELIMITER"), - TokenPattern(Lexeme((")",), space_before=True), "END_DELIMITER"), - TokenPattern("response", "QUANTITY"), - TokenPattern("answer", "QUANTITY"), - CatchAll("INPUT", "INPUT"), - ] - token_list += [TokenPattern(Lexeme((x,), space_before=True, space_after=True), " "+x+" ") for x in criteria_operations] - - productions = [ - ("START", "BOOL", create_node), - ("BOOL", "QUANTITY matches QUANTITY", infix), - ("BOOL", "DIMENSION matches DIMENSION", infix), - ("BOOL", "QUANTITY=QUANTITY", infix), - ("BOOL", "QUANTITY<=QUANTITY", infix), - ("BOOL", "QUANTITY>=QUANTITY", infix), - ("BOOL", "QUANTITYQUANTITY", infix), - ("QUANTITY", "INPUT", create_node), - ("DIMENSION", "dimension(QUANTITY)", operate(1)), - ] - - return SLRParser(token_list, productions, start_symbol, end_symbol, null_symbol) diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 996bf58..da904b1 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -5,6 +5,7 @@ from sympy import Basic, Equality, im, I from sympy import re as real_part +from lambdafeedback.criteria import where_substitutions from lambdafeedback.expression_parsing import SympyParsingConfig, parse_expression from lambdafeedback.slr_parsing import ExprNode @@ -22,23 +23,9 @@ def unsimplified_with_unsplittable(parsing_params: SympyParsingConfig, symbols: ) -def where_substitution_nodes(subs_node: ExprNode) -> list[ExprNode]: - """The individual substitutions in the substitution part of a WHERE criterion.""" - if subs_node.label == "EQUALITY": - return [subs_node] - elif subs_node.label == "SEPARATOR": - return subs_node.children - return subs_node # type: ignore[return-value] # Bug 6 in the bug report: should be a list or raise - - def parse_where_substitutions(subs_node: ExprNode, parsing_params: SympyParsingConfig) -> SubstitutionList: """`[(name, expression), ...]` for each substitution in the substitution part of a WHERE criterion.""" - local_subs = [] - for sub in where_substitution_nodes(subs_node): - name = sub.children[0].content_string() - expr = parse_expression(sub.children[1].content_string(), parsing_params) - local_subs.append((name, expr)) - return local_subs + return [(name, parse_expression(expr, parsing_params)) for name, expr in where_substitutions(subs_node)] def check_criterion(criterion: ExprNode, parameters: EvaluationParameters) -> bool | None: diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 8146b8e..7f9c20f 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -4,12 +4,12 @@ from sympy import Basic -from lambdafeedback.criteria import CriteriaGraph, OutputNode, ReachedCriteria +from lambdafeedback.criteria import CriteriaGraph, OutputNode, ReachedCriteria, where_substitutions from lambdafeedback.expression_parsing import parse_expression from lambdafeedback.slr_parsing import ExprNode from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag -from app.context.symbolic.comparison import check_equality, parse_where_substitutions, where_substitution_nodes +from app.context.symbolic.comparison import check_equality, parse_where_substitutions from app.context.symbolic.variations import one_addition_to_subtraction, one_exponent_flip, one_swap_addition_and_multiplication from app.evaluation_parameters import EvaluationParameters from app.types import Substitutions @@ -143,7 +143,7 @@ def get_candidates(_response: Any) -> ReachedCriteria: def criterion_where_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: parsing_params = parameters.parsing_parameters expression = criterion.children[0] - subs = where_substitution_nodes(criterion.children[1]) + subs = where_substitutions(criterion.children[1]) local_subs = parse_where_substitutions(criterion.children[1], parsing_params) if label is None: label = criterion.content_string() @@ -152,7 +152,7 @@ def expression_check(_response: Any) -> ReachedCriteria: result = check_equality(expression, parameters, local_substitutions=local_subs) return outcome_tag(label, result is True) - subs_string = ", ".join([s.content_string() for s in subs]) + subs_string = ", ".join(name+" = "+expr for name, expr in subs) graph, END = outcome_graph( label, summary=label, diff --git a/app/docs/dev.md b/app/docs/dev.md index 2065d31..6864a2e 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -53,7 +53,7 @@ The context is a frozen `Context` dataclass, defined in `context/base.py`, with - `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. - `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. - `feedback_procedure_generator` function that generates a function for each criteria that can be used to evaluate if the criteria is satisfied or not. The output from this function should be a list of tags that the feedback string generator can use to produce human readable feedback. -- `generate_criteria_parser` function that generates a parser that can be used to turn the criteria (given in string form) into a form that the feedback generation procedure can use to determine if they are correct or not. +- `generate_criteria_parser` function that generates a parser that can be used to turn the criteria (given in string form) into a form that the feedback generation procedure can use to determine if they are correct or not. Both contexts use parsers from `lambdafeedback.criteria`: `build_criteria_parser` (symbolic) and `build_quantity_criteria_parser` (physical quantity). The criteria string is split into individual criteria with `lambdafeedback.criteria.split_criteria` before parsing. - `default_criteria` the criteria used when none are given in `params`. - `parsing_parameters_generator` function that builds the parsing parameters passed to `expression_parse` from the evaluation parameters. @@ -215,7 +215,7 @@ When `is_latex` is enabled, `e` and `E` are read as plain symbols by the LaTeX p # Packages -The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.2` release candidate. Each package's `CHANGELOG.md` lists the API changes: +The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.3` release candidate. Each package's `CHANGELOG.md` lists the API changes: | Package | Import | Used for | |---|---|---| diff --git a/app/evaluation.py b/app/evaluation.py index 5f7af5f..2679d1f 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -4,12 +4,13 @@ from copy import deepcopy from typing import Any -from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.criteria import CriteriaGraph, CriteriaSyntaxError, split_criteria from lambdafeedback.slr_parsing import ExprNode, SLRParser from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, ExpressionParams, + notation_warnings, parse_latex, ) @@ -169,30 +170,6 @@ def get_criteria_string(context: Context, parameters: Parameters) -> str: return criteria -def split_criteria(criteria_string: str) -> list[str]: - """Splits `criteria_string` on the commas that are not inside brackets, and strips each criterion.""" - criteria_string_list = [] - delims = [ - ("(", ")"), - ("[", "]"), - ("{", "}"), - ] - depth = {delim: 0 for delim in delims} - delim_key = {delim[0]: delim for delim in delims} - delim_key.update({delim[1]: delim for delim in delims}) - criterion_start = 0 - for n, c in enumerate(criteria_string): - if c in [delim[0] for delim in delims]: - depth[delim_key[c]] += 1 - if c in [delim[1] for delim in delims]: - depth[delim_key[c]] -= 1 - if c == "," and all([d == 0 for d in depth.values()]): - criteria_string_list.append(criteria_string[criterion_start:n].strip()) - criterion_start = n+1 - criteria_string_list.append(criteria_string[criterion_start:].strip()) - return criteria_string_list - - def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameters: Parameters) -> dict[str, ExprNode]: preprocess = context.expression_preprocess original_criteria_string = get_criteria_string(context, parameters) @@ -201,8 +178,12 @@ def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameter except (BracketNotationError, AbsoluteValueNotationError) as e: feedback = notation_error_feedback(e, original_criteria_string) raise Exception(internal_feedback(feedback), str(feedback.tag)) from e + try: + criteria_string_list = split_criteria(criteria_string) + except CriteriaSyntaxError as e: + raise Exception("Cannot parse criteria: `"+criteria_string+"`.") from e criteria_parsed: dict[str, ExprNode] = {} - for criterion in split_criteria(criteria_string): + for criterion in criteria_string_list: try: criterion_tokens = criteria_parser.scan(criterion) criterion_parsed = criteria_parser.parse(criterion_tokens)[0] @@ -304,24 +285,11 @@ def _build_parameters(context: Context, params: dict[str, Any], response: str, a return parameters -def notation_warnings(response: str, strict_syntax: bool) -> list[str]: - """The tags of the notation warnings for `response`.""" - # FIXME: Move these notation warnings into lambdafeedback.expression_parsing - tags = [] - if strict_syntax: - if "^" in response: - tags.append("NOTATION_WARNING_EXPONENT") - if "!" in response: - tags.append("NOTATION_WARNING_FACTORIAL") - if "!!!" in response: - tags.append("NOTATION_WARNING_TRIPLE_FACTORIAL") - return tags - def _add_notation_warnings(evaluation_result: EvaluationResult, response: str, params: Parameters) -> None: symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] - for tag in notation_warnings(response, params.get("strict_syntax", False)): - evaluation_result.add_feedback(tag, symbolic_comparison_internal_messages(tag)(dict())) + for warning in notation_warnings(response, bool(params.get("strict_syntax", False))): + evaluation_result.add_feedback(str(warning), symbolic_comparison_internal_messages(str(warning))(dict())) def _set_preview_fields( diff --git a/app/requirements.txt b/app/requirements.txt index 94aabae..943d048 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,9 +3,9 @@ mpmath==1.3.0 sympy==1.14 # Parsing, units and criteria packages extracted from this function # (https://github.com/lambda-feedback/lambda-feedback-packages), pinned to the -# v0.3.0-rc.2 release candidate (0.3.0rc2). All four are listed: they depend on +# v0.3.0-rc.3 release candidate (0.3.0rc3). All four are listed: they depend on # each other by name and aren't published on PyPI. -lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=slr_parsing -lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=criteria -lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=expression_parsing -lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=units +lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.3#subdirectory=slr_parsing +lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.3#subdirectory=criteria +lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.3#subdirectory=expression_parsing +lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.3#subdirectory=units diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index ff1e44f..53271c0 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -2118,6 +2118,23 @@ def test_equality_expression_mismatch_with_custom_criteria(self): result = evaluation_function(response, answer, params) assert result["is_correct"] is False + @pytest.mark.parametrize( + "criteria", + [ + "response=answer,", + "response=answer,, response-answer=0", + ] + ) + def test_malformed_criteria_list_cannot_be_parsed(self, criteria): + params = { + "criteria": criteria, + "strict_syntax": False, + "elementary_functions": True, + } + with pytest.raises(Exception) as e: + evaluation_function("x+1", "1+x", params) + assert "Cannot parse criteria" in str(e.value) + def test_input_symbols_takes_priority_when_containing_elementary_function_names_as_substring(self): response = "Vmax - Vmaxe^-(t/tau)" answer = "Vmax*(1-exp(-t/tau))" From 0eaae119fcd592c7251dacf557c43e12865644bf Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 15:00:17 +0100 Subject: [PATCH 23/25] Refactor `parameters_dict` to `parameters`: update function signatures, internal references, and type hints across symbolic evaluation modules to streamline parameter usage and improve consistency. --- app/context/physical_quantity/__init__.py | 6 ++-- app/context/symbolic/__init__.py | 6 ++-- app/context/symbolic/comparison.py | 34 +++++++++--------- app/context/symbolic/graphs/equivalence.py | 36 +++++++++---------- app/context/symbolic/graphs/generic.py | 4 +-- .../symbolic/graphs/set_equivalence.py | 12 +++---- app/context/symbolic/graphs/substitution.py | 10 +++--- .../symbolic/graphs/syntactical_form.py | 6 ++-- app/context/symbolic/numerical.py | 16 ++++----- 9 files changed, 65 insertions(+), 65 deletions(-) diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py index 4a7cdec..af35556 100644 --- a/app/context/physical_quantity/__init__.py +++ b/app/context/physical_quantity/__init__.py @@ -33,11 +33,11 @@ def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str: } -def feedback_procedure_generator(parameters_dict: EvaluationParameters) -> dict[str, CriteriaGraph]: +def feedback_procedure_generator(parameters: EvaluationParameters) -> dict[str, CriteriaGraph]: graphs = dict() - for (label, criterion) in parameters_dict.criteria.items(): + for (label, criterion) in parameters.criteria.items(): graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) - graphs.update({label: graph_template(criterion, parameters_dict)}) + graphs.update({label: graph_template(criterion, parameters)}) return graphs diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py index f6761d5..264c3f7 100644 --- a/app/context/symbolic/__init__.py +++ b/app/context/symbolic/__init__.py @@ -46,12 +46,12 @@ def expression_parse(name: str, expr: str, parameters: SympyParsingConfig, evalu } -def feedback_procedure_generator(parameters_dict: EvaluationParameters) -> dict[str, CriteriaGraph]: +def feedback_procedure_generator(parameters: EvaluationParameters) -> dict[str, CriteriaGraph]: graphs = dict() - criteria = set(parameters_dict.criteria.items()) + criteria = set(parameters.criteria.items()) for (label, criterion) in criteria: graph_template = graph_templates.get(criterion.label, criterion_eval_node) - graphs.update({label: graph_template(criterion, parameters_dict)}) + graphs.update({label: graph_template(criterion, parameters)}) return graphs diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index 30850a8..996bf58 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -41,26 +41,26 @@ def parse_where_substitutions(subs_node: ExprNode, parsing_params: SympyParsingC return local_subs -def check_criterion(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool | None: +def check_criterion(criterion: ExprNode, parameters: EvaluationParameters) -> bool | None: label = criterion.label.strip() if label in {"EQUALITY", "WRITTEN_AS"}: - result = check_equality(criterion, parameters_dict) + result = check_equality(criterion, parameters) elif label == "ORDER": - result = check_order(criterion, parameters_dict) + result = check_order(criterion, parameters) elif label == "CONTAINS": - result = check_contains_symbol(criterion, parameters_dict) + result = check_contains_symbol(criterion, parameters) elif label == "PROPORTIONAL_TO": - result = check_proportionality(criterion, parameters_dict) + result = check_proportionality(criterion, parameters) else: raise Exception(label) return result def create_expressions_for_comparison( - criterion: ExprNode, parameters_dict: EvaluationParameters, local_substitutions: Substitutions = () + criterion: ExprNode, parameters: EvaluationParameters, local_substitutions: Substitutions = () ) -> tuple[Basic, Basic]: - reserved_expressions = list(parameters_dict.reserved_expressions.items()) - parsing_params = unsimplified_with_unsplittable(parameters_dict.parsing_parameters, parameters_dict.reserved_expressions.keys()) + reserved_expressions = list(parameters.reserved_expressions.items()) + parsing_params = unsimplified_with_unsplittable(parameters.parsing_parameters, parameters.reserved_expressions.keys()) lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) @@ -101,9 +101,9 @@ def do_comparison(comparison_symbol: str, expression: Basic) -> bool | None: def check_equality( - criterion: ExprNode, parameters_dict: EvaluationParameters, local_substitutions: Substitutions = () + criterion: ExprNode, parameters: EvaluationParameters, local_substitutions: Substitutions = () ) -> bool | None: - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters, local_substitutions) if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality): return False elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality): @@ -138,17 +138,17 @@ def check_equality( if result is not False: return result - return compare_numerically(lhs_expr, rhs_expr, parameters_dict) + return compare_numerically(lhs_expr, rhs_expr, parameters) -def check_order(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool | None: - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) +def check_order(criterion: ExprNode, parameters: EvaluationParameters) -> bool | None: + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters) result = do_comparison(criterion.content, lhs_expr-rhs_expr) return result -def check_proportionality(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool | None: - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) +def check_proportionality(criterion: ExprNode, parameters: EvaluationParameters) -> bool | None: + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters) result = None if lhs_expr.cancel().simplify().simplify() != 0: result = (rhs_expr/lhs_expr).cancel().simplify() @@ -161,7 +161,7 @@ def check_proportionality(criterion: ExprNode, parameters_dict: EvaluationParame return result -def check_contains_symbol(criterion: ExprNode, parameters_dict: EvaluationParameters) -> bool: - lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict) +def check_contains_symbol(criterion: ExprNode, parameters: EvaluationParameters) -> bool: + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters) result = rhs_expr in lhs_expr.atoms() return result diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index b2c9de3..3597132 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -15,11 +15,11 @@ from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -def _equality_equivalence(label: str, parameters_dict: EvaluationParameters) -> Evaluate: +def _equality_equivalence(label: str, parameters: EvaluationParameters) -> Evaluate: def equality_equivalence(_response: Any) -> ReachedCriteria: result = False - res = parameters_dict.reserved_expressions["response"] - ans = parameters_dict.reserved_expressions["answer"] + res = parameters.reserved_expressions["response"] + ans = parameters.reserved_expressions["answer"] if (not isinstance(res, Equality)) and isinstance(ans, Equality): return { @@ -40,15 +40,15 @@ def equality_equivalence(_response: Any) -> ReachedCriteria: symbols_in_equality_ratio = res_difference.free_symbols else: symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict.parsing_parameters.constants) + result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters.parsing_parameters.constants) return outcome_tag(label, result is True) return equality_equivalence -def _same_symbols(label: str, lhs: str, rhs: str, parameters_dict: EvaluationParameters) -> Evaluate: +def _same_symbols(label: str, lhs: str, rhs: str, parameters: EvaluationParameters) -> Evaluate: def same_symbols(_response: Any) -> ReachedCriteria: - local_substitutions = list(parameters_dict.reserved_expressions.items()) - parsing_params = unsimplified_with_unsplittable(parameters_dict.parsing_parameters, parameters_dict.reserved_expressions.keys()) + local_substitutions = list(parameters.reserved_expressions.items()) + parsing_params = unsimplified_with_unsplittable(parameters.parsing_parameters, parameters.reserved_expressions.keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols @@ -57,14 +57,14 @@ def same_symbols(_response: Any) -> ReachedCriteria: def add_equality_equivalence_nodes( - graph: CriteriaGraph, END: OutputNode, parameters_dict: EvaluationParameters, label: str, lhs: str, rhs: str + graph: CriteriaGraph, END: OutputNode, parameters: EvaluationParameters, label: str, lhs: str, rhs: str ) -> None: """Adds the nodes that check if the equalities `lhs` and `rhs` are equivalent to `graph`.""" graph.add_evaluation_node( label, summary=label, details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", - evaluate=_equality_equivalence(label, parameters_dict) + evaluate=_equality_equivalence(label, parameters) ) attach_terminal_criterion( graph, @@ -114,14 +114,14 @@ def add_equality_equivalence_nodes( def add_mathematical_equivalence_nodes( - graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: EvaluationParameters, label: str, lhs: str, rhs: str + graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters: EvaluationParameters, label: str, lhs: str, rhs: str ) -> None: """ Adds the nodes that check if `lhs` and `rhs` are mathematically equal to `graph`, and if they are, whether they have the same symbols. """ def mathematical_equivalence(_response: Any) -> ReachedCriteria: - return outcome_tag(label, check_equality(criterion, parameters_dict)) + return outcome_tag(label, check_equality(criterion, parameters)) graph.add_evaluation_node( label, @@ -141,7 +141,7 @@ def mathematical_equivalence(_response: Any) -> ReachedCriteria: label+"_SAME_SYMBOLS", summary=str(lhs)+" has the same symbols as "+str(rhs), details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", - evaluate=_same_symbols(label, lhs, rhs, parameters_dict) + evaluate=_same_symbols(label, lhs, rhs, parameters) ) attach_terminal_criterion( graph, @@ -181,7 +181,7 @@ def mathematical_equivalence(_response: Any) -> ReachedCriteria: ) -def criterion_equality_node(criterion: ExprNode, parameters_dict: EvaluationParameters, label: str | None = None) -> CriteriaGraph: +def criterion_equality_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: if label is None: label = criterion.content_string() @@ -190,14 +190,14 @@ def criterion_equality_node(criterion: ExprNode, parameters_dict: EvaluationPara lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() - res = parameters_dict.reserved_expressions["response"] - ans = parameters_dict.reserved_expressions["answer"] + res = parameters.reserved_expressions["response"] + ans = parameters.reserved_expressions["answer"] use_set_equivalence, response_list, answer_list = as_sets(res, ans) if use_set_equivalence is True: - add_set_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs, response_list, answer_list) + add_set_equivalence_nodes(graph, END, criterion, parameters, label, lhs, rhs, response_list, answer_list) elif isinstance(res, Equality) or isinstance(ans, Equality): - add_equality_equivalence_nodes(graph, END, parameters_dict, label, lhs, rhs) + add_equality_equivalence_nodes(graph, END, parameters, label, lhs, rhs) else: - add_mathematical_equivalence_nodes(graph, END, criterion, parameters_dict, label, lhs, rhs) + add_mathematical_equivalence_nodes(graph, END, criterion, parameters, label, lhs, rhs) return graph diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index f8d38e7..6fc80bf 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -11,12 +11,12 @@ from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -def criterion_eval_node(criterion: ExprNode, parameters_dict: EvaluationParameters) -> CriteriaGraph: +def criterion_eval_node(criterion: ExprNode, parameters: EvaluationParameters) -> CriteriaGraph: feedback_string_generator_inputs = {'criterion': criterion} label = criterion.content_string() def evaluation_node_internal(_response: Any) -> ReachedCriteria: - result = check_criterion(criterion, parameters_dict) + result = check_criterion(criterion, parameters) return outcome_tag(label, result, feedback_string_generator_inputs) graph, END = outcome_graph(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index 48be7d8..ec063f7 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -29,7 +29,7 @@ def as_sets(response: Expression, answer: Expression) -> tuple[bool, Expression, def add_set_equivalence_nodes( - graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters_dict: EvaluationParameters, label: str, lhs: str, rhs: str, + graph: CriteriaGraph, END: OutputNode, criterion: ExprNode, parameters: EvaluationParameters, label: str, lhs: str, rhs: str, response_list: set[Basic], answer_list: set[Basic], ) -> None: """ @@ -45,30 +45,30 @@ def set_equivalence(_response: Any) -> ReachedCriteria: if isinstance(response, Equality) != isinstance(answer, Equality): result = False else: - result = check_equality(criterion, parameters_dict, local_substitutions=current_pair) + result = check_equality(criterion, parameters, local_substitutions=current_pair) if result is True: matches["responses"][i] = True matches["answers"][j] = True - if parameters_dict.multiple_answers_criteria == "all": + if parameters.multiple_answers_criteria == "all": is_correct = all(matches["responses"]) and all(matches["answers"]) if is_correct is False: return { label+"_MULTIPLE_ANSWER_FAIL_ALL": None } - elif parameters_dict.multiple_answers_criteria == "all_responses": + elif parameters.multiple_answers_criteria == "all_responses": is_correct = all(matches["responses"]) if is_correct is False: return { label+"_MULTIPLE_ANSWER_FAIL_RESPONSE": None } - elif parameters_dict.multiple_answers_criteria == "all_answers": + elif parameters.multiple_answers_criteria == "all_answers": is_correct = all(matches["answers"]) if is_correct is False: return { label+"_MULTIPLE_ANSWER_FAIL_ANSWER": None } else: - raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters_dict.multiple_answers_criteria}") + raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters.multiple_answers_criteria}") return { label+"_TRUE": None } diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 884049b..8146b8e 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -140,8 +140,8 @@ def get_candidates(_response: Any) -> ReachedCriteria: ) -def criterion_where_node(criterion: ExprNode, parameters_dict: EvaluationParameters, label: str | None = None) -> CriteriaGraph: - parsing_params = parameters_dict.parsing_parameters +def criterion_where_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: + parsing_params = parameters.parsing_parameters expression = criterion.children[0] subs = where_substitution_nodes(criterion.children[1]) local_subs = parse_where_substitutions(criterion.children[1], parsing_params) @@ -149,7 +149,7 @@ def criterion_where_node(criterion: ExprNode, parameters_dict: EvaluationParamet label = criterion.content_string() def expression_check(_response: Any) -> ReachedCriteria: - result = check_equality(expression, parameters_dict, local_substitutions=local_subs) + result = check_equality(expression, parameters, local_substitutions=local_subs) return outcome_tag(label, result is True) subs_string = ", ".join([s.content_string() for s in subs]) @@ -180,8 +180,8 @@ def expression_check(_response: Any) -> ReachedCriteria: if expression_to_vary is None: return graph - reserved_expressions = list(parameters_dict.reserved_expressions.items()) - response_value = parameters_dict.reserved_expressions["response"].subs(local_subs) + reserved_expressions = list(parameters.reserved_expressions.items()) + response_value = parameters.reserved_expressions["response"].subs(local_subs) expression_to_vary = parse_expression(expression_to_vary.content_string(), parsing_params).subs(reserved_expressions) variation_groups = _variation_groups(criterion, expression_to_vary) values_and_expressions, values_and_variations_group = _group_values(expression_to_vary, variation_groups, local_subs) diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index b977e3c..c7d1732 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -60,7 +60,7 @@ def _add_known_form_nodes(graph: CriteriaGraph, label: str, lhs: str, rhs: str, graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") -def criterion_written_as_node(criterion: ExprNode, parameters_dict: EvaluationParameters, label: str | None = None) -> CriteriaGraph: +def criterion_written_as_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: if label is None: label = criterion.content_string() @@ -74,8 +74,8 @@ def criterion_written_as_node(criterion: ExprNode, parameters_dict: EvaluationPa return graph # TODO: Make a version that can compare the forms of any two reserved expressions - answer = parameters_dict.reserved_expressions_strings["task"]["answer"] - response = parameters_dict.reserved_expressions_strings["learner"]["response"] + answer = parameters.reserved_expressions_strings["task"]["answer"] + response = parameters.reserved_expressions_strings["learner"]["response"] graph.add_evaluation_node( str(rhs)+"_WRITTEN_AS_KNOWN_FORM", diff --git a/app/context/symbolic/numerical.py b/app/context/symbolic/numerical.py index c15207b..7adbd28 100644 --- a/app/context/symbolic/numerical.py +++ b/app/context/symbolic/numerical.py @@ -4,16 +4,16 @@ from app.evaluation_parameters import EvaluationParameters -def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters_dict: EvaluationParameters) -> bool: +def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters: EvaluationParameters) -> bool: """ Compares `lhs_expr` and `rhs_expr` numerically using the `atol` and `rtol` parameters. Returns False if numerical comparison is not enabled (`numerical` is not set and neither `atol` nor `rtol` is positive), otherwise whether the difference is within both tolerances. """ - if not (parameters_dict.numerical - or float(parameters_dict.rtol) > 0 - or float(parameters_dict.atol) > 0): + if not (parameters.numerical + or float(parameters.rtol) > 0 + or float(parameters.atol) > 0): return False # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. @@ -29,18 +29,18 @@ def replace_pi(expr: Basic) -> Basic: # Separates LHS and RHS, parses and evaluates them res = N(replace_pi(lhs_expr)) ans = N(replace_pi(rhs_expr)) - if float(parameters_dict.atol) > 0: + if float(parameters.atol) > 0: try: absolute_error = abs(float(ans-res)) - error_below_atol = bool(absolute_error < float(parameters_dict.atol)) + error_below_atol = bool(absolute_error < float(parameters.atol)) except TypeError: error_below_atol = None else: error_below_atol = True - if float(parameters_dict.rtol) > 0: + if float(parameters.rtol) > 0: try: relative_error = abs(float((ans-res)/ans)) - error_below_rtol = bool(relative_error < float(parameters_dict.rtol)) + error_below_rtol = bool(relative_error < float(parameters.rtol)) except TypeError: error_below_rtol = None else: From 659ab57e52d6a9d97b4a5f192fe6bfa0f95500b3 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 16 Oct 2026 11:33:28 +0100 Subject: [PATCH 24/25] Update dev docs to reflect absolute import path changes in `app/types.py` --- 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 7318f14..951100f 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -241,7 +241,7 @@ The tests are in `tests/` and fall into two main groups: evaluation tests (`eval The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. # Imports -Modules import each other with absolute imports rooted at the `app` package (e.g. `from app.context.types import Parameters`), including the tests; relative imports are not used. This works both locally (run `pytest` and `mypy` from the repository root) and in the deployed image, which sets `PYTHONPATH=/app` and imports the function as `app.handler`. +Modules import each other with absolute imports rooted at the `app` package (e.g. `from app.types import Parameters`), including the tests; relative imports are not used. This works both locally (run `pytest` and `mypy` from the repository root) and in the deployed image, which sets `PYTHONPATH=/app` and imports the function as `app.handler`. # Type checking All code in `app/` except the tests is type annotated and checked with mypy, using the configuration in `mypy.ini`. Run `mypy` from the repository root; the tools used for testing, linting and type checking (including the pinned mypy version) are listed in `requirements-dev.txt`, which CI also installs. Shared type aliases, such as `Parameters` and `GraphTemplate`, are in `types.py`. Each `# type: ignore` comment says why it is there; most mark a place where an honest type exposes a known bug. \ No newline at end of file From cedb336c6d2f210cf07b7c3db60cab3bb5f7d0d9 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 23 Oct 2026 09:53:01 +0100 Subject: [PATCH 25/25] Spaced around operators --- app/context/graph_helpers.py | 6 +- app/context/physical_quantity/comparison.py | 6 +- app/context/physical_quantity/graphs/match.py | 68 +++++++------- app/context/physical_quantity/graphs/order.py | 6 +- app/context/symbolic/comparison.py | 20 ++--- app/context/symbolic/graphs/equivalence.py | 90 +++++++++---------- app/context/symbolic/graphs/generic.py | 14 +-- .../symbolic/graphs/set_equivalence.py | 26 +++--- app/context/symbolic/graphs/substitution.py | 60 ++++++------- .../symbolic/graphs/syntactical_form.py | 56 ++++++------ app/context/symbolic/numerical.py | 4 +- app/context/symbolic/variations.py | 10 +-- app/evaluation.py | 16 ++-- .../physical_quantity_evaluation_test.py | 36 ++++---- app/tests/symbolic_evaluation_test.py | 18 ++-- 15 files changed, 218 insertions(+), 218 deletions(-) diff --git a/app/context/graph_helpers.py b/app/context/graph_helpers.py index 7412dc9..1f9f0f5 100644 --- a/app/context/graph_helpers.py +++ b/app/context/graph_helpers.py @@ -11,11 +11,11 @@ def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | None = None) -> ReachedCriteria: """`{label+"_TRUE": inputs}` if `result` is True, `label+"_FALSE"` if it is False, otherwise `label+"_UNKNOWN"`.""" if result is True: - return {label+"_TRUE": inputs} + return {label + "_TRUE": inputs} elif result is False: - return {label+"_FALSE": inputs} + return {label + "_FALSE": inputs} else: - return {label+"_UNKNOWN": inputs} + return {label + "_UNKNOWN": inputs} def outcome_graph(label: str, summary: str, details: str, evaluate: Evaluate) -> tuple[CriteriaGraph, OutputNode]: diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py index cb8664c..bbe828e 100644 --- a/app/context/physical_quantity/comparison.py +++ b/app/context/physical_quantity/comparison.py @@ -33,17 +33,17 @@ def comparison_function(comparison_operator: str) -> Callable[[Basic, Basic, Sub def comparison_function_inner(lhs: Basic, rhs: Basic, substitutions: Substitutions) -> bool: expr0, expr1 = _substitute(lhs, rhs, substitutions) - return bool(comparison((expr0-expr1).cancel().simplify().simplify(), 0)) + return bool(comparison((expr0 - expr1).cancel().simplify().simplify(), 0)) return comparison_function_inner def is_proportional(lhs: Basic, rhs: Basic, substitutions: Substitutions) -> tuple[bool | None, float | None]: expr0, expr1 = _substitute(lhs, rhs, substitutions) if expr0.cancel().simplify().simplify() != 0: - ratio = (expr0/expr1).cancel().simplify() + ratio = (expr0 / expr1).cancel().simplify() result = ratio.is_constant() elif expr0.cancel().simplify().simplify() != 0: - ratio = (expr1/expr0).cancel().simplify() + ratio = (expr1 / expr0).cancel().simplify() result = ratio.is_constant() else: result = False diff --git a/app/context/physical_quantity/graphs/match.py b/app/context/physical_quantity/graphs/match.py index 33c4324..e5109f0 100644 --- a/app/context/physical_quantity/graphs/match.py +++ b/app/context/physical_quantity/graphs/match.py @@ -37,13 +37,13 @@ def _missing_or_unexpected(parameters: EvaluationParameters, label: str, lhs_str res_unit = parameters.reserved_expressions["response"].original_unit ans_unit = parameters.reserved_expressions["answer"].original_unit if res_value is None and ans_value is not None: - return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} + return {label + "_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} if res_value is not None and ans_value is None: - return {label+"_UNEXPECTED_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} + return {label + "_UNEXPECTED_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} if res_unit is None and ans_unit is not None: - return {label+"_MISSING_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} + return {label + "_MISSING_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} if res_unit is not None and ans_unit is None: - return {label+"_UNEXPECTED_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} + return {label + "_UNEXPECTED_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} return None @@ -58,10 +58,10 @@ def _within_tolerance(parameters: EvaluationParameters, lhs_string: str, rhs_str res = parameters.reserved_expressions["response"].standard_value.simplify() if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): if rtol > 0 and (ans != 0): - value_match = bool(abs(float((ans-res)/ans)) < rtol) + value_match = bool(abs(float((ans - res) / ans)) < rtol) elif atol > 0 or (ans == 0): answer_unit_factor = float(parameters.reserved_expressions["answer"].quantity.unit_factor) - value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) + value_match = bool(abs(float(ans - res)) < atol * answer_unit_factor) return value_match @@ -93,7 +93,7 @@ def _dimension_match(parameters: EvaluationParameters, label: str, lhs: Basic, r def dimension_match(_response: Any) -> ReachedCriteria: substitutions = [(key, expr.dimension) for (key, expr) in reserved_expressions] dimension_match, _ = is_proportional(lhs, rhs, substitutions) - return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) + return outcome_tag(label + "_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string}) return dimension_match @@ -105,7 +105,7 @@ def unit_comparison(_response: Any) -> ReachedCriteria: if is_equal(lhs, rhs, substitutions) is True: output_tags = { - label+"_UNIT_COMPARISON"+"_IDENTICAL": None + label + "_UNIT_COMPARISON" + "_IDENTICAL": None } else: local_substitutions = [(key, expr.quantity.expanded_unit) for (key, expr) in reserved_expressions] @@ -113,15 +113,15 @@ def unit_comparison(_response: Any) -> ReachedCriteria: # `is_proportional` returns a ratio whenever the result is True if result is True and ratio is not None and ratio >= 1000: output_tags = { - label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE": None + label + "_UNIT_COMPARISON" + "_PREFIX_IS_LARGE": None } - elif result is True and ratio is not None and ratio <= 1/1000: + elif result is True and ratio is not None and ratio <= 1 / 1000: output_tags = { - label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL": None + label + "_UNIT_COMPARISON" + "_PREFIX_IS_SMALL": None } else: output_tags = { - label+"_UNIT_COMPARISON"+"_SIMILAR": None + label + "_UNIT_COMPARISON" + "_SIMILAR": None } return output_tags @@ -140,14 +140,14 @@ def _add_match_nodes( ) graph.attach_new_criterion( label, - label+"_TRUE", + label + "_TRUE", summary=f"The quantities {inputs[0]} and {inputs[1]} match.", details=f"The quantities {inputs[0]} and {inputs[1]} match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MATCH") ) graph.attach_new_criterion( label, - label+"_FALSE", + label + "_FALSE", summary=f"The quantities {inputs[0]} and {inputs[1]} does not match.", details=f"The quantities {inputs[0]} and {inputs[1]} does not match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MISMATCH") @@ -155,7 +155,7 @@ def _add_match_nodes( attach_terminal_criterion( graph, label, - label+"_MISSING_VALUE", + label + "_MISSING_VALUE", END, summary="The response is missing a value.", details="The response is missing a value.", @@ -164,7 +164,7 @@ def _add_match_nodes( attach_terminal_criterion( graph, label, - label+"_UNEXPECTED_VALUE", + label + "_UNEXPECTED_VALUE", END, summary="The response is expected only have unit(s), no value.", details="The response is expected only have unit(s), no value.", @@ -173,7 +173,7 @@ def _add_match_nodes( attach_terminal_criterion( graph, label, - label+"_MISSING_UNIT", + label + "_MISSING_UNIT", END, summary="The response is missing unit(s).", details="The response is missing unit(s).", @@ -182,23 +182,23 @@ def _add_match_nodes( attach_terminal_criterion( graph, label, - label+"_UNEXPECTED_UNIT", + label + "_UNEXPECTED_UNIT", END, summary="The response is expected to be a value without unit(s).", details="The response is expected to be a value without unit(s).", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") ) graph.attach_new_evaluation( - label+"_FALSE", - label+"_DIMENSION_MATCH", + label + "_FALSE", + label + "_DIMENSION_MATCH", summary=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", details=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", evaluate=dimension_match ) attach_terminal_criterion( graph, - label+"_DIMENSION_MATCH", - label+"_DIMENSION_MATCH"+"_TRUE", + label + "_DIMENSION_MATCH", + label + "_DIMENSION_MATCH" + "_TRUE", END, summary=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", details=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", @@ -206,38 +206,38 @@ def _add_match_nodes( ) attach_terminal_criterion( graph, - label+"_DIMENSION_MATCH", - label+"_DIMENSION_MATCH"+"_FALSE", + label + "_DIMENSION_MATCH", + label + "_DIMENSION_MATCH" + "_FALSE", END, summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", details=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MISMATCH") ) graph.attach_new_evaluation( - label+"_TRUE", - label+"_UNIT_COMPARISON", + label + "_TRUE", + label + "_UNIT_COMPARISON", summary=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", details=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", evaluate=unit_comparison ) graph.attach_new_criterion( - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_IDENTICAL", + label + "_UNIT_COMPARISON", + label + "_UNIT_COMPARISON" + "_IDENTICAL", summary=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", details=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_IDENTICAL") ) graph.attach_new_criterion( - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_SIMILAR", + label + "_UNIT_COMPARISON", + label + "_UNIT_COMPARISON" + "_SIMILAR", summary=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", details=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_SIMILAR") ) attach_terminal_criterion( graph, - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", + label + "_UNIT_COMPARISON", + label + "_UNIT_COMPARISON" + "_PREFIX_IS_LARGE", END, summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", details=f"The units of {inputs[0]} are at least 1000 times greater than the units of {inputs[1]}.", @@ -245,8 +245,8 @@ def _add_match_nodes( ) attach_terminal_criterion( graph, - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", + label + "_UNIT_COMPARISON", + label + "_UNIT_COMPARISON" + "_PREFIX_IS_SMALL", END, summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", details=f"The units of {inputs[0]} are at least 1000 times smaller than the units of {inputs[1]}.", diff --git a/app/context/physical_quantity/graphs/order.py b/app/context/physical_quantity/graphs/order.py index fbc9df8..8ba4dd1 100644 --- a/app/context/physical_quantity/graphs/order.py +++ b/app/context/physical_quantity/graphs/order.py @@ -22,7 +22,7 @@ def comparison_base_graph( inputs = criterion.children _, _, lhs, rhs = parse_criterion_sides(criterion, parameters) - stardard_forms = [(key, expr.standard_value*expr.standard_unit) for (key, expr) in reserved_expressions] + stardard_forms = [(key, expr.standard_value * expr.standard_unit) for (key, expr) in reserved_expressions] compare = comparison_function(comparison_operator) def compare_evaluate(_response: Any) -> ReachedCriteria: @@ -37,7 +37,7 @@ def compare_evaluate(_response: Any) -> ReachedCriteria: attach_terminal_criterion( graph, label, - label+"_TRUE", + label + "_TRUE", END, summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", details=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", @@ -46,7 +46,7 @@ def compare_evaluate(_response: Any) -> ReachedCriteria: attach_terminal_criterion( graph, label, - label+"_FALSE", + label + "_FALSE", END, summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", details=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py index da904b1..d3e2181 100644 --- a/app/context/symbolic/comparison.py +++ b/app/context/symbolic/comparison.py @@ -19,7 +19,7 @@ def unsimplified_with_unsplittable(parsing_params: SympyParsingConfig, symbols: """`parsing_params` without simplification, with `symbols` also read as single symbols.""" return parsing_params.replace( simplify=False, - extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(symbols), + extra_unsplittable_symbols =parsing_params.extra_unsplittable_symbols + tuple(symbols), ) @@ -56,8 +56,8 @@ def create_expressions_for_comparison( simplified_lhs_expr = lhs_expr.cancel().simplify() simplified_rhs_expr = rhs_expr.cancel().simplify() if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): - lhs_expr = real_part(simplified_lhs_expr) + I*im(simplified_lhs_expr) - rhs_expr = real_part(simplified_rhs_expr) + I*im(simplified_rhs_expr) + lhs_expr = real_part(simplified_lhs_expr) + I * im(simplified_lhs_expr) + rhs_expr = real_part(simplified_rhs_expr) + I * im(simplified_rhs_expr) return lhs_expr, rhs_expr @@ -103,7 +103,7 @@ def check_equality( if either_is_infinite: result = do_comparison_infinite(criterion.content, lhs_expr, rhs_expr) else: - result = do_comparison(criterion.content, lhs_expr-rhs_expr) + result = do_comparison(criterion.content, lhs_expr - rhs_expr) if result is not False: return result @@ -112,16 +112,16 @@ def check_equality( # simplifications here in order to reduce the likelihood of false negatives. # Each side is simplified only once a retry needs it. simplified_rhs_expr = rhs_expr.simplify() - result = do_comparison(criterion.content, lhs_expr-simplified_rhs_expr) + result = do_comparison(criterion.content, lhs_expr - simplified_rhs_expr) if result is not False: return result simplified_lhs_expr = lhs_expr.simplify() - result = do_comparison(criterion.content, simplified_lhs_expr-rhs_expr) + result = do_comparison(criterion.content, simplified_lhs_expr - rhs_expr) if result is not False: return result - result = do_comparison(criterion.content, simplified_lhs_expr-simplified_rhs_expr) + result = do_comparison(criterion.content, simplified_lhs_expr - simplified_rhs_expr) if result is not False: return result @@ -130,7 +130,7 @@ def check_equality( def check_order(criterion: ExprNode, parameters: EvaluationParameters) -> bool | None: lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters) - result = do_comparison(criterion.content, lhs_expr-rhs_expr) + result = do_comparison(criterion.content, lhs_expr - rhs_expr) return result @@ -138,9 +138,9 @@ def check_proportionality(criterion: ExprNode, parameters: EvaluationParameters) lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters) result = None if lhs_expr.cancel().simplify().simplify() != 0: - result = (rhs_expr/lhs_expr).cancel().simplify() + result = (rhs_expr / lhs_expr).cancel().simplify() elif rhs_expr.cancel().simplify().simplify() != 0: - result = (lhs_expr/rhs_expr).cancel().simplify() + result = (lhs_expr / rhs_expr).cancel().simplify() if result == 0 or result is None: result = False else: diff --git a/app/context/symbolic/graphs/equivalence.py b/app/context/symbolic/graphs/equivalence.py index 3597132..1c6eb27 100644 --- a/app/context/symbolic/graphs/equivalence.py +++ b/app/context/symbolic/graphs/equivalence.py @@ -23,23 +23,23 @@ def equality_equivalence(_response: Any) -> ReachedCriteria: if (not isinstance(res, Equality)) and isinstance(ans, Equality): return { - label+"_EXPRESSION_NOT_EQUALITY": None + label + "_EXPRESSION_NOT_EQUALITY": None } if isinstance(res, Equality) and (not isinstance(ans, Equality)): return { - label+"_EQUALITY_NOT_EXPRESSION": None + label + "_EQUALITY_NOT_EXPRESSION": None } # TODO: Remove when criteria for checking proportionality is implemented if isinstance(res, Equality) and isinstance(ans, Equality): - res_difference = (res.args[0]-res.args[1]).simplify() + res_difference = (res.args[0] - res.args[1]).simplify() if res_difference == 0: - symbols_in_equality_ratio = (ans.args[0]-ans.args[1]).simplify().free_symbols - elif (ans.args[0]-ans.args[1]).simplify() == 0: + symbols_in_equality_ratio = (ans.args[0] - ans.args[1]).simplify().free_symbols + elif (ans.args[0] - ans.args[1]).simplify() == 0: symbols_in_equality_ratio = res_difference.free_symbols else: - symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols + symbols_in_equality_ratio = ((res.args[0] - res.args[1]) / (ans.args[0] - ans.args[1])).simplify().free_symbols result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters.parsing_parameters.constants) return outcome_tag(label, result is True) return equality_equivalence @@ -52,7 +52,7 @@ def same_symbols(_response: Any) -> ReachedCriteria: lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols - return outcome_tag(label+"_SAME_SYMBOLS", result is True) + return outcome_tag(label + "_SAME_SYMBOLS", result is True) return same_symbols @@ -63,52 +63,52 @@ def add_equality_equivalence_nodes( graph.add_evaluation_node( label, summary=label, - details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", + details ="Checks if " + str(lhs) + " is equivalent to " + str(rhs) + ".", evaluate=_equality_equivalence(label, parameters) ) attach_terminal_criterion( graph, label, - label+"_UNKNOWN", + label + "_UNKNOWN", END, - summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), - details="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs)+".", + summary ="Cannot determine if " + str(lhs) + " is equivalent to " + str(rhs), + details ="Cannot determine if " + str(lhs) + " is equivalent to " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN") ) attach_terminal_criterion( graph, label, - label+"_TRUE", + label + "_TRUE", END, - summary=str(lhs)+" is equivalent to "+str(rhs), - details=str(lhs)+" is equivalent to "+str(rhs)+".", + summary =str(lhs) + " is equivalent to " + str(rhs), + details =str(lhs) + " is equivalent to " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") ) attach_terminal_criterion( graph, label, - label+"_FALSE", + label + "_FALSE", END, - summary=str(lhs)+" is not equivalent to "+str(rhs), - details=str(lhs)+" is not equivalent to "+str(rhs)+".", + summary =str(lhs) + " is not equivalent to " + str(rhs), + details =str(lhs) + " is not equivalent to " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") ) attach_terminal_criterion( graph, label, - label+"_EXPRESSION_NOT_EQUALITY", + label + "_EXPRESSION_NOT_EQUALITY", END, - summary=str(lhs)+" is an expression, not an equality.", - details=str(lhs)+" is an expression, not an equality.", + summary =str(lhs) + " is an expression, not an equality.", + details =str(lhs) + " is an expression, not an equality.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") ) attach_terminal_criterion( graph, label, - label+"_EQUALITY_NOT_EXPRESSION", + label + "_EQUALITY_NOT_EXPRESSION", END, - summary=str(lhs)+" is an equality, not an expression.", - details=str(lhs)+" is an equality, not an expression.", + summary =str(lhs) + " is an equality, not an expression.", + details =str(lhs) + " is an equality, not an expression.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") ) @@ -126,57 +126,57 @@ def mathematical_equivalence(_response: Any) -> ReachedCriteria: graph.add_evaluation_node( label, summary=label, - details="Checks if "+str(lhs)+"="+str(rhs)+".", + details ="Checks if " + str(lhs) + "=" + str(rhs) + ".", evaluate=mathematical_equivalence ) graph.attach_new_criterion( label, - label+"_TRUE", - summary=str(lhs)+"="+str(rhs), - details=str(lhs)+" is equal to "+str(rhs)+".", + label + "_TRUE", + summary =str(lhs) + "=" + str(rhs), + details =str(lhs) + " is equal to " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) graph.attach_new_evaluation( - label+"_TRUE", - label+"_SAME_SYMBOLS", - summary=str(lhs)+" has the same symbols as "+str(rhs), - details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", + label + "_TRUE", + label + "_SAME_SYMBOLS", + summary =str(lhs) + " has the same symbols as " + str(rhs), + details =str(lhs) + " has the same (free) symbols as " + str(rhs) + ".", evaluate=_same_symbols(label, lhs, rhs, parameters) ) attach_terminal_criterion( graph, - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_TRUE", + label + "_SAME_SYMBOLS", + label + "_SAME_SYMBOLS" + "_TRUE", END, - summary=str(lhs)+" has the same symbols as "+str(rhs), - details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", + summary =str(lhs) + " has the same symbols as " + str(rhs), + details =str(lhs) + " has the same (free) symbols as " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") ) attach_terminal_criterion( graph, - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_FALSE", + label + "_SAME_SYMBOLS", + label + "_SAME_SYMBOLS" + "_FALSE", END, - summary=str(lhs)+" does not have the same symbols as "+str(rhs), - details=str(lhs)+" does note have the same (free) symbols as "+str(rhs)+".", + summary =str(lhs) + " does not have the same symbols as " + str(rhs), + details =str(lhs) + " does note have the same (free) symbols as " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") ) attach_terminal_criterion( graph, label, - label+"_FALSE", + label + "_FALSE", END, - summary=str(lhs)+"=\\="+str(rhs), - details=str(lhs)+" is not equal to"+str(rhs)+".", + summary =str(lhs) + "=\\=" + str(rhs), + details =str(lhs) + " is not equal to" + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") ) attach_terminal_criterion( graph, label, - label+"_UNKNOWN", + label + "_UNKNOWN", END, - summary="Cannot detrmine if "+str(lhs)+"="+str(rhs), - details="Cannot detrmine if "+str(lhs)+" is equal to "+str(rhs)+".", + summary ="Cannot detrmine if " + str(lhs) + "=" + str(rhs), + details ="Cannot detrmine if " + str(lhs) + " is equal to " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("UNKNOWN") ) diff --git a/app/context/symbolic/graphs/generic.py b/app/context/symbolic/graphs/generic.py index 6fc80bf..66534d3 100644 --- a/app/context/symbolic/graphs/generic.py +++ b/app/context/symbolic/graphs/generic.py @@ -19,32 +19,32 @@ def evaluation_node_internal(_response: Any) -> ReachedCriteria: result = check_criterion(criterion, parameters) return outcome_tag(label, result, feedback_string_generator_inputs) - graph, END = outcome_graph(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) + graph, END = outcome_graph(label, summary=label, details="Checks if " + label + " is true.", evaluate=evaluation_node_internal) attach_terminal_criterion( graph, label, - label+"_TRUE", + label + "_TRUE", END, summary="True", - details=label+" is true.", + details =label + " is true.", feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") ) attach_terminal_criterion( graph, label, - label+"_FALSE", + label + "_FALSE", END, summary="True", - details=label+" is false.", + details =label + " is false.", feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") ) attach_terminal_criterion( graph, label, - label+"_UNKNOWN", + label + "_UNKNOWN", END, summary="True", - details=label+" is false.", + details =label + " is false.", feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") ) return graph diff --git a/app/context/symbolic/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py index ec063f7..318cb8e 100644 --- a/app/context/symbolic/graphs/set_equivalence.py +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -37,7 +37,7 @@ def add_set_equivalence_nodes( `answer_list` (and vice versa, depending on `multiple_answers_criteria`) to `graph`. """ def set_equivalence(_response: Any) -> ReachedCriteria: - matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} + matches = {"responses": [False] * len(response_list), "answers": [False] * len(answer_list)} for i, response in enumerate(response_list): for j, answer in enumerate(answer_list): current_pair = [("response", response), ("answer", answer)] @@ -53,52 +53,52 @@ def set_equivalence(_response: Any) -> ReachedCriteria: is_correct = all(matches["responses"]) and all(matches["answers"]) if is_correct is False: return { - label+"_MULTIPLE_ANSWER_FAIL_ALL": None + label + "_MULTIPLE_ANSWER_FAIL_ALL": None } elif parameters.multiple_answers_criteria == "all_responses": is_correct = all(matches["responses"]) if is_correct is False: return { - label+"_MULTIPLE_ANSWER_FAIL_RESPONSE": None + label + "_MULTIPLE_ANSWER_FAIL_RESPONSE": None } elif parameters.multiple_answers_criteria == "all_answers": is_correct = all(matches["answers"]) if is_correct is False: return { - label+"_MULTIPLE_ANSWER_FAIL_ANSWER": None + label + "_MULTIPLE_ANSWER_FAIL_ANSWER": None } else: raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters.multiple_answers_criteria}") return { - label+"_TRUE": None + label + "_TRUE": None } graph.add_evaluation_node( label, summary=label, - details="Checks if "+str(lhs)+"="+str(rhs)+".", + details ="Checks if " + str(lhs) + "=" + str(rhs) + ".", evaluate=set_equivalence ) graph.attach_new_criterion( label, - label+"_TRUE", - summary=str(lhs)+"="+str(rhs), - details=str(lhs)+" is equal to "+str(rhs)+".", + label + "_TRUE", + summary =str(lhs) + "=" + str(rhs), + details =str(lhs) + " is equal to " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) attach_terminal_criterion( graph, label, - label+"_MULTIPLE_ANSWER_FAIL_ALL", + label + "_MULTIPLE_ANSWER_FAIL_ALL", END, - summary=str(lhs)+" is not equal to "+str(rhs), + summary =str(lhs) + " is not equal to " + str(rhs), details="At least one answer or response was incorrect.", feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") ) attach_terminal_criterion( graph, label, - label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", + label + "_MULTIPLE_ANSWER_FAIL_RESPONSE", END, summary="Unexpected element in response.", details="At least one response was incorrect.", @@ -107,7 +107,7 @@ def set_equivalence(_response: Any) -> ReachedCriteria: attach_terminal_criterion( graph, label, - label+"_MULTIPLE_ANSWER_FAIL_ANSWER", + label + "_MULTIPLE_ANSWER_FAIL_ANSWER", END, summary="Missing element in response", details="At least one answer is missing in the response.", diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py index 7f9c20f..10c470b 100644 --- a/app/context/symbolic/graphs/substitution.py +++ b/app/context/symbolic/graphs/substitution.py @@ -38,21 +38,21 @@ def _variation_groups(criterion: ExprNode, expression_to_vary: Basic) -> dict[st return { "ONE_ADDITION_TO_SUBTRACTION": { "variations": one_addition_to_subtraction(expression_to_vary), - "summary": expression_string+" if one addition is changed to a subtraction or vice versa.", + "summary": expression_string + " if one addition is changed to a subtraction or vice versa.", }, "ONE_EXPONENT_FLIP": { "variations": one_exponent_flip(expression_to_vary), - "summary": expression_string+" is true if one exponent has its sign changed.", + "summary": expression_string + " is true if one exponent has its sign changed.", }, "ONE_SWAP_ADDITION_AND_MULTIPLICATION": { "variations": one_swap_addition_and_multiplication(expression_to_vary), - "summary": expression_string+" is true if one addition is replaced with a multiplication or vice versa.", + "summary": expression_string + " is true if one addition is replaced with a multiplication or vice versa.", } } def _variation_details(variations: list[Basic]) -> str: - return "The following expressions are checked: "+", ".join([str(e) for e in variations]) + return "The following expressions are checked: " + ", ".join([str(e) for e in variations]) def _group_values( @@ -82,20 +82,20 @@ def _add_identify_reason_nodes(graph: CriteriaGraph, END: OutputNode, label: str """Adds the nodes that try to identify which variation of the expression the response matches.""" def identify_reason(_response: Any) -> ReachedCriteria: reasons = { - label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) + label + "_" + group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) } return reasons graph.attach_new_evaluation( - label+"_FALSE", - label+"_IDENTIFY_REASON", + label + "_FALSE", + label + "_IDENTIFY_REASON", summary="Identify reason.", details="Attempt to identify why the response is incorrect.", evaluate=identify_reason ) attach_terminal_criterion( graph, - label+"_IDENTIFY_REASON", - label+"_UNKNOWN", + label + "_IDENTIFY_REASON", + label + "_UNKNOWN", END, summary="Unknown reason", details="No candidates for how the response was computed were found.", @@ -104,23 +104,23 @@ def identify_reason(_response: Any) -> ReachedCriteria: def get_candidates(_response: Any) -> ReachedCriteria: candidates = { - label+"_RESPONSE_CANDIDATES_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] + label + "_RESPONSE_CANDIDATES_" + group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] } return candidates for (group_label, group_info) in variation_groups.items(): graph.attach_new_criterion( - label+"_IDENTIFY_REASON", - label+"_"+group_label, + label + "_IDENTIFY_REASON", + label + "_" + group_label, summary=group_info["summary"], details=_variation_details(group_info["variations"]), feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) graph.attach_new_evaluation( - label+"_"+group_label, - label+"_GET_CANDIDATES_"+group_label, - summary="Get candidate responses that satisfy "+expression.content_string(), - details="Get candidate responses that satisfy "+expression.content_string(), + label + "_" + group_label, + label + "_GET_CANDIDATES_" + group_label, + summary ="Get candidate responses that satisfy " + expression.content_string(), + details ="Get candidate responses that satisfy " + expression.content_string(), evaluate=get_candidates ) @@ -128,15 +128,15 @@ def get_candidates(_response: Any) -> ReachedCriteria: expressions_string = ", ".join([str(e) for e in expressions]) for group_label in values_and_variations_group[value]: if group_label != "UNKNOWN": - group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] - if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: + group_candidates_eval = graph.evaluations[label + "_GET_CANDIDATES_" + group_label] + if label + "_RESPONSE_CANDIDATES_" + group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: attach_terminal_criterion( graph, - label+"_GET_CANDIDATES_"+group_label, - label+"_RESPONSE_CANDIDATES_"+group_label, + label + "_GET_CANDIDATES_" + group_label, + label + "_RESPONSE_CANDIDATES_" + group_label, END, - summary="response = "+str(value), - details="Response candidates: "+expressions_string + summary ="response = " + str(value), + details ="Response candidates: " + expressions_string ) @@ -152,27 +152,27 @@ def expression_check(_response: Any) -> ReachedCriteria: result = check_equality(expression, parameters, local_substitutions=local_subs) return outcome_tag(label, result is True) - subs_string = ", ".join(name+" = "+expr for name, expr in subs) + subs_string = ", ".join(name + " = " + expr for name, expr in subs) graph, END = outcome_graph( label, summary=label, - details="Checks if "+expression.content_string()+" where "+subs_string+".", + details ="Checks if " + expression.content_string() + " where " + subs_string + ".", evaluate=expression_check ) attach_terminal_criterion( graph, label, - label+"_TRUE", + label + "_TRUE", END, - summary=expression.content_string()+" where "+subs_string, - details=expression.content_string()+" where "+subs_string+"is true.", + summary =expression.content_string() + " where " + subs_string, + details =expression.content_string() + " where " + subs_string + "is true.", feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") ) graph.attach_new_criterion( label, - label+"_FALSE", - summary="not "+expression.content_string(), - details=expression.content_string()+" is not true when "+subs_string+".", + label + "_FALSE", + summary ="not " + expression.content_string(), + details =expression.content_string() + " is not true when " + subs_string + ".", feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("FALSE") ) diff --git a/app/context/symbolic/graphs/syntactical_form.py b/app/context/symbolic/graphs/syntactical_form.py index c7d1732..d6636f9 100644 --- a/app/context/symbolic/graphs/syntactical_form.py +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -19,9 +19,9 @@ def find_form(_response: Any) -> ReachedCriteria: if syntactical_forms[form_label].matcher(string) is True: forms.add(form_label) if len(forms) > 0: - return {name+"_WRITTEN_AS_"+form_label: None for form_label in forms} + return {name + "_WRITTEN_AS_" + form_label: None for form_label in forms} else: - return {name+"_WRITTEN_AS_UNKNOWN": None} + return {name + "_WRITTEN_AS_UNKNOWN": None} return find_form @@ -42,22 +42,22 @@ def _add_known_form_nodes(graph: CriteriaGraph, label: str, lhs: str, rhs: str, """For each known form, adds nodes that check if `lhs` is written in that form when `rhs` is.""" for form_label in syntactical_forms.keys(): graph.attach_new_criterion( - str(rhs)+"_WRITTEN_AS_KNOWN_FORM", - str(rhs)+"_WRITTEN_AS_"+form_label, - summary=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", - details=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", + str(rhs) + "_WRITTEN_AS_KNOWN_FORM", + str(rhs) + "_WRITTEN_AS_" + form_label, + summary =str(rhs) + " in written in " + syntactical_forms[form_label].name + " form.", + details =str(rhs) + " in written in " + syntactical_forms[form_label].name + " form.", feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"](form_label), ) graph.attach_new_evaluation( - str(rhs)+"_WRITTEN_AS_"+form_label, - str(lhs)+"_COMPARE_FORM_TO_"+form_label, - summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label].name+" form.", - details="Checks if "+str(lhs)+" is written in "+syntactical_forms[form_label].name+" form.", + str(rhs) + "_WRITTEN_AS_" + form_label, + str(lhs) + "_COMPARE_FORM_TO_" + form_label, + summary ="Is " + str(lhs) + " written in " + syntactical_forms[form_label].name + " form.", + details ="Checks if " + str(lhs) + " is written in " + syntactical_forms[form_label].name + " form.", evaluate=compare_to_form(response, form_label, label), - sufficiencies={str(rhs)+"_WRITTEN_AS_"+form_label} + sufficiencies ={str(rhs) + "_WRITTEN_AS_" + form_label} ) - graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_TRUE") - graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") + graph.attach(str(lhs) + "_COMPARE_FORM_TO_" + form_label, label + "_TRUE") + graph.attach(str(lhs) + "_COMPARE_FORM_TO_" + form_label, label + "_FALSE") def criterion_written_as_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph: @@ -78,38 +78,38 @@ def criterion_written_as_node(criterion: ExprNode, parameters: EvaluationParamet response = parameters.reserved_expressions_strings["learner"]["response"] graph.add_evaluation_node( - str(rhs)+"_WRITTEN_AS_KNOWN_FORM", + str(rhs) + "_WRITTEN_AS_KNOWN_FORM", summary=label, details=f"Checks if {rhs} is written in a known form.", evaluate=written_as_known_form(answer, rhs) ) graph.attach_new_criterion( - str(rhs)+"_WRITTEN_AS_KNOWN_FORM", - str(rhs)+"_WRITTEN_AS_UNKNOWN", - summary=str(rhs)+" is not written in a known form.", - details=str(rhs)+" is not written in a known form.", + str(rhs) + "_WRITTEN_AS_KNOWN_FORM", + str(rhs) + "_WRITTEN_AS_UNKNOWN", + summary =str(rhs) + " is not written in a known form.", + details =str(rhs) + " is not written in a known form.", feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"]("UNKNOWN") ) graph.attach_new_evaluation( - str(rhs)+"_WRITTEN_AS_UNKNOWN", + str(rhs) + "_WRITTEN_AS_UNKNOWN", label, - summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".", - details="Checks if "+str(lhs)+" is written in the same form as "+str(rhs)+".", + summary ="Is " + str(lhs) + " written in the same form as " + str(rhs) + ".", + details ="Checks if " + str(lhs) + " is written in the same form as " + str(rhs) + ".", evaluate=response_written_as_answer(response, answer, label), - sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"} + sufficiencies ={str(rhs) + "_WRITTEN_AS_UNKNOWN"} ) graph.attach_new_criterion( label, - label+"_TRUE", - summary=str(rhs)+" written in the same form as "+str(rhs)+".", - details=str(rhs)+" written in the same form as "+str(rhs)+".", + label + "_TRUE", + summary =str(rhs) + " written in the same form as " + str(rhs) + ".", + details =str(rhs) + " written in the same form as " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("TRUE") ) graph.attach_new_criterion( label, - label+"_FALSE", - summary=str(rhs)+" written in the same form as "+str(rhs)+".", - details=str(rhs)+" written in the same form as "+str(rhs)+".", + label + "_FALSE", + summary =str(rhs) + " written in the same form as " + str(rhs) + ".", + details =str(rhs) + " written in the same form as " + str(rhs) + ".", feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("FALSE") ) _add_known_form_nodes(graph, label, lhs, rhs, response) diff --git a/app/context/symbolic/numerical.py b/app/context/symbolic/numerical.py index 7adbd28..2cb51c2 100644 --- a/app/context/symbolic/numerical.py +++ b/app/context/symbolic/numerical.py @@ -31,7 +31,7 @@ def replace_pi(expr: Basic) -> Basic: ans = N(replace_pi(rhs_expr)) if float(parameters.atol) > 0: try: - absolute_error = abs(float(ans-res)) + absolute_error = abs(float(ans - res)) error_below_atol = bool(absolute_error < float(parameters.atol)) except TypeError: error_below_atol = None @@ -39,7 +39,7 @@ def replace_pi(expr: Basic) -> Basic: error_below_atol = True if float(parameters.rtol) > 0: try: - relative_error = abs(float((ans-res)/ans)) + relative_error = abs(float((ans - res) / ans)) error_below_rtol = bool(relative_error < float(parameters.rtol)) except TypeError: error_below_rtol = None diff --git a/app/context/symbolic/variations.py b/app/context/symbolic/variations.py index 659c8da..d65b6ed 100644 --- a/app/context/symbolic/variations.py +++ b/app/context/symbolic/variations.py @@ -15,7 +15,7 @@ def find_coords_for_node_type(expression: Basic, node_type: type[Basic]) -> list if isinstance(expr, node_type): node_coords.append(coord) for (k, arg) in enumerate(expr.args): - stack.append((arg, coord+(k,))) + stack.append((arg, coord + (k,))) return node_coords @@ -31,7 +31,7 @@ def replace_node_variations( for k in range(0, len(nodes[-1].args)): variation = replacement_function(nodes[-1], k) for (node, coord) in reversed(list(zip(nodes, coords))): - new_args = node.args[0:coord]+(variation,)+node.args[coord+1:] + new_args = node.args[0:coord] + (variation,) + node.args[coord + 1:] variation = type(node)(*new_args) variations.append(variation) return variations @@ -39,7 +39,7 @@ def replace_node_variations( def one_addition_to_subtraction(expression: Basic) -> list[Basic]: def addition_to_subtraction(node: Basic, k: int) -> Basic: - return node - 2*node.args[k] + return node - 2 * node.args[k] variations = replace_node_variations(expression, Add, addition_to_subtraction) return variations @@ -47,10 +47,10 @@ def addition_to_subtraction(node: Basic, k: int) -> Basic: def one_swap_addition_and_multiplication(expression: Basic) -> list[Basic]: def addition_to_multiplication(node: Basic, k: int) -> Basic: - return node - node.args[k-1] - node.args[k] + node.args[k-1] * node.args[k] + return node - node.args[k - 1] - node.args[k] + node.args[k - 1] * node.args[k] def multiplication_to_addition(node: Basic, k: int) -> Basic: - return node - 2*node.args[k] + return node - 2 * node.args[k] variations = replace_node_variations(expression, Add, addition_to_multiplication) variations += replace_node_variations(expression, Mul, addition_to_multiplication) diff --git a/app/evaluation.py b/app/evaluation.py index 2679d1f..788685a 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -56,9 +56,9 @@ def _raise_on_collisions(keywords: Iterable[str], symbols: Collection[str], kind """Raises if any of `keywords` is used as an input symbol `kind` (e.g. "code" or "alternative").""" collisions = sorted(keyword for keyword in keywords if keyword in symbols) if len(collisions) == 1: - raise Exception("`"+"`, `".join(collisions)+f"` is a reserved keyword and cannot be used as an input symbol {kind}.") + raise Exception("`" + "`, `".join(collisions) + f"` is a reserved keyword and cannot be used as an input symbol {kind}.") elif len(collisions) > 1: - raise Exception("`"+"`, `".join(collisions)+f"` are reserved keywords and cannot be used as input symbol {kind}s.") + raise Exception("`" + "`, `".join(collisions) + f"` are reserved keywords and cannot be used as input symbol {kind}s.") def select_context(parameters: Parameters) -> Context: @@ -166,7 +166,7 @@ def get_criteria_string(context: Context, parameters: Parameters) -> str: if criteria is None: criteria = ",".join(context.default_criteria) if (parameters.get("syntactical_comparison", False) is True) and ("responsewrittenasanswer" not in "".join(criteria.split())): - criteria = criteria+", response written as answer" + criteria = criteria + ", response written as answer" return criteria @@ -181,7 +181,7 @@ def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameter try: criteria_string_list = split_criteria(criteria_string) except CriteriaSyntaxError as e: - raise Exception("Cannot parse criteria: `"+criteria_string+"`.") from e + raise Exception("Cannot parse criteria: `" + criteria_string + "`.") from e criteria_parsed: dict[str, ExprNode] = {} for criterion in criteria_string_list: try: @@ -192,7 +192,7 @@ def create_criteria_dict(context: Context, criteria_parser: SLRParser, parameter raise TypeError(f"criterion {criterion_parsed.content_string()!r} is given more than once") criteria_parsed.update({criterion_parsed.content_string(): criterion_parsed}) except Exception as e: - raise Exception("Cannot parse criteria: `"+criterion+"`.") from e + raise Exception("Cannot parse criteria: `" + criterion + "`.") from e return criteria_parsed @@ -211,7 +211,7 @@ def generate_feedback(criteria_graphs: Mapping[str, CriteriaGraph], evaluation_p custom_feedback = evaluation_parameters.custom_feedback for (criterion_identifier, graph) in criteria_graphs.items(): # TODO: Find better way to identify main criteria for criteria graph - main_criteria = criterion_identifier+"_TRUE" + main_criteria = criterion_identifier + "_TRUE" criteria_feedback = graph.generate_feedback(response, main_criteria) # TODO: Implement way to define completeness of task other than "all main criteria satisfied" @@ -260,10 +260,10 @@ def _build_parameters(context: Context, params: dict[str, Any], response: str, a if "criteria" not in parameters.keys(): parameters.update({"criteria": ",".join(context.default_criteria)}) - reserved_expressions_keys = list(reserved_expressions_strings["learner"].keys())+list(reserved_expressions_strings["task"].keys()) + reserved_expressions_keys = list(reserved_expressions_strings["learner"].keys()) + list(reserved_expressions_strings["task"].keys()) parameters.update( { - "reserved_keywords": reserved_keywords+reserved_expressions_keys, + "reserved_keywords": reserved_keywords + reserved_expressions_keys, } ) parsing_parameters = context.parsing_parameters_generator(parameters, unsplittable_symbols=reserved_expressions_keys) diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index d18b0e2..77f2710 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -74,11 +74,11 @@ def test_slow_quantity_alternative_names_natural_syntax(self): for u1 in units: for u2 in units: if u1 is not u2: - answer = prefix[0]+u1[0]+" "+u2[0] - for u1_alt in (u1[0],)+u1[3]: - for u2_alt in (u2[0],)+u2[3]+u2[4]: + answer = prefix[0] + u1[0] + " " + u2[0] + for u1_alt in (u1[0],) + u1[3]: + for u2_alt in (u2[0],) + u2[3] + u2[4]: n += 1 - response = prefix[0]+u1_alt+u2_alt + response = prefix[0] + u1_alt + u2_alt try: result = evaluation_function(response, answer, params) except Exception: @@ -86,8 +86,8 @@ def test_slow_quantity_alternative_names_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - m = len(errors)+len(incorrect) - details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) + m = len(errors) + len(incorrect) + details = "Total: " + str(m) + "/" + str(n) + "\nIncorrect:\n" + "".join([str(x) + "\n" for x in incorrect]) + "\nErrors:\n" + "".join([str(x) + "\n" for x in errors]) # Current number of collisions caused by concatenating two units, e.g. "barnewton" has "barn" as substring, "aremole" has "rem" as substring etc. assert m <= 144, details @@ -101,9 +101,9 @@ def test_slow_quantity_short_forms_natural_syntax(self): for prefix in SI_PREFIXES: for u1 in units: for u2 in units: - if u1 is not u2 and (prefix[1]+u1[1] not in [u[1] for u in units]): - answer = prefix[0]+u1[0]+" "+u2[0] - response = prefix[1]+u1[1]+u2[1] + if u1 is not u2 and (prefix[1] + u1[1] not in [u[1] for u in units]): + answer = prefix[0] + u1[0] + " " + u2[0] + response = prefix[1] + u1[1] + u2[1] n += 1 try: result = evaluation_function(response, answer, params) @@ -113,8 +113,8 @@ def test_slow_quantity_short_forms_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - m = len(errors)+len(incorrect) - details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) + m = len(errors) + len(incorrect) + details = "Total: " + str(m) + "/" + str(n) + "\nIncorrect:\n" + "".join([str(x) + "\n" for x in incorrect]) + "\nErrors:\n" + "".join([str(x) + "\n" for x in errors]) # Current number of collisions caused by concatenating short forms for units, e.g. 'femtometre inch' and 'femtominute' both have short form 'fmin' assert m <= 551, details @@ -126,16 +126,16 @@ def test_slow_quantity_short_forms_natural_syntax(self): ("0.105", "kg m/s^2", 0.0004, 0.0006), ("0.0010", "kg m/s^2", 0.00004, 0.00006), ("100", "kg m/s^2", 0.4, 0.6), - ("100*10**10", "kg m/s^2", 4*10**9, 6*10**9) + ("100*10**10", "kg m/s^2", 4 * 10**9, 6 * 10**9) ] ) @pytest.mark.slow def test_compute_relative_tolerance_from_significant_digits(self, value, unit, small_diff, large_diff): - ans = value+" "+unit - res_correct_under = str(eval(value)-small_diff)+" "+unit - res_correct_over = str(eval(value)+small_diff)+" "+unit - res_incorrect_under = str(eval(value)-large_diff)+" "+unit - res_incorrect_over = str(eval(value)+large_diff)+" "+unit + ans = value + " " + unit + res_correct_under = str(eval(value) - small_diff) + " " + unit + res_correct_over = str(eval(value) + small_diff) + " " + unit + res_incorrect_under = str(eval(value) - large_diff) + " " + unit + res_incorrect_over = str(eval(value) + large_diff) + " " + unit params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict"} assert evaluation_function(res_correct_under, ans, params)["is_correct"] is True assert evaluation_function(res_correct_over, ans, params)["is_correct"] is True @@ -271,7 +271,7 @@ def test_order_operators(self, response, answer, order_operator, value): "strict_syntax": False, "physical_quantity": True, "elementary functions": True, - "criteria": "response "+order_operator+" answer" + "criteria": "response " + order_operator + " answer" } result = evaluation_function(response, answer, params, include_test_data=True) assert result["is_correct"] is value diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 53271c0..aedfefb 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -79,7 +79,7 @@ def generate_input_variations_from_elementary_function_aliases(response, answer, for alternative in substitution[1]: current_substitutions = [(c, a[0]) for (c, a) in alias_substitutions[0:k]] current_substitutions.append((substitution[0], alternative)) - current_substitutions += [(c, a[0]) for (c, a) in alias_substitutions[(k+1):]] + current_substitutions += [(c, a[0]) for (c, a) in alias_substitutions[(k + 1):]] subs_answer = substitute(answer, current_substitutions) subs_response = substitute(response, current_substitutions) input_variations += [(subs_response, subs_answer)] @@ -1026,7 +1026,7 @@ def test_PHYS40002_2_2_b(self, response, outcome): "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)", "6cos(5x+1)-90x*sin(5x+1)-225x^2cos(5x+1)+125x^3sin(5x+1)", ] - ]+[ + ] + [ ( res, False @@ -1049,7 +1049,7 @@ def test_PHYS40002_2_6_a(self, response, outcome): sum( [ [ - (case[0],)+var + (case[0],) + var for var in generate_input_variations_from_elementary_function_aliases( case[1], case[2], @@ -1092,7 +1092,7 @@ def test_no_reserved_keywords_in_input_symbol_codes(self): { keyword: { "aliases": [], - "latex": r"\mathrm{"+keyword+r"}" + "latex": r"\mathrm{" + keyword + r"}" } } ) @@ -1102,7 +1102,7 @@ def test_no_reserved_keywords_in_input_symbol_codes(self): with pytest.raises(Exception) as e: evaluation_function(response, answer, params) for keyword in reserved_keywords: - assert "`"+keyword+"`" in str(e.value) + assert "`" + keyword + "`" in str(e.value) def test_no_reserved_keywords_in_input_symbol_alternatives(self): reserved_keywords = ["response", "answer"] @@ -1118,7 +1118,7 @@ def test_no_reserved_keywords_in_input_symbol_alternatives(self): { labels[label_index]: { "aliases": [keyword], - "latex": r"\mathrm{"+keyword+r"}" + "latex": r"\mathrm{" + keyword + r"}" } } ) @@ -1129,7 +1129,7 @@ def test_no_reserved_keywords_in_input_symbol_alternatives(self): with pytest.raises(Exception) as e: evaluation_function(response, answer, params) for keyword in reserved_keywords: - assert "`"+keyword+"`" in str(e.value) + assert "`" + keyword + "`" in str(e.value) def test_no_reserved_keywords_in_old_format_input_symbol_codes(self): reserved_keywords = ["where", "written as"] @@ -1146,7 +1146,7 @@ def test_no_reserved_keywords_in_old_format_input_symbol_codes(self): with pytest.raises(Exception) as e: evaluation_function(response, answer, params) for keyword in reserved_keywords: - assert "`"+keyword+"`" in str(e.value) + assert "`" + keyword + "`" in str(e.value) def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): reserved_keywords = ["response", "answer"] @@ -1166,7 +1166,7 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): with pytest.raises(Exception) as e: evaluation_function(response, answer, params) for keyword in reserved_keywords: - assert "`"+keyword+"`" in str(e.value) + assert "`" + keyword + "`" in str(e.value) @pytest.mark.parametrize( "response, answer, criteria, value, feedback_tags, additional_params",