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 0f1e91d..c2705c5 100644 --- a/.github/workflows/test-lint.yml +++ b/.github/workflows/test-lint.yml @@ -28,18 +28,23 @@ 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/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/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/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 new file mode 100644 index 0000000..662ac91 --- /dev/null +++ b/app/context/graph_helpers.py @@ -0,0 +1,38 @@ +"""Helpers for building the `CriteriaGraph`s shared by the symbolic and physical quantity contexts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator, OutputNode, ReachedCriteria + + +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} + elif result is False: + return {label+"_FALSE": inputs} + else: + return {label+"_UNKNOWN": inputs} + + +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, + label, + summary=summary, + details=details, + feedback_string_generator=feedback_string_generator + ) + graph.attach(label, end.label) 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.py b/app/context/physical_quantity.py deleted file mode 100644 index b178fc9..0000000 --- a/app/context/physical_quantity.py +++ /dev/null @@ -1,514 +0,0 @@ -# ------- -# 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.units import QuantityParams, preprocess_quantity -from lambdafeedback.units import parse_quantity as parse_physical_quantity -from .physical_quantity_preview 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 - - -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): - if compare(lhs, rhs, stardard_forms) is True: - return {label+"_TRUE": None} - else: - return {label+"_FALSE": None} - - 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 - ) - graph.attach_new_criterion( - label, - label+"_TRUE", - 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( - label, - label+"_FALSE", - 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 - - -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) - - 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 - - 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 - - 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") - ) - graph.attach_new_criterion( - label, - label+"_MISSING_VALUE", - 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( - label, - label+"_UNEXPECTED_VALUE", - 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( - label, - label+"_MISSING_UNIT", - 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( - label, - label+"_UNEXPECTED_UNIT", - 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", - 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 - ) - graph.attach_new_criterion( - label+"_DIMENSION_MATCH", - label+"_DIMENSION_MATCH"+"_TRUE", - 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( - label+"_DIMENSION_MATCH", - label+"_DIMENSION_MATCH"+"_FALSE", - 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", - 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") - ) - graph.attach_new_criterion( - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", - 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( - label+"_UNIT_COMPARISON", - label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", - 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 - - -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) - for evaluation in graph.evaluations.values(): - if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END_LABEL - graphs.update({label: graph}) - return graphs - - -# The evaluation parameters that affect how quantities are read. Other parameters -# (e.g. `symbols`, `units_string` and `is_latex`) have never applied when reading -# the response and answer as quantities during evaluation, only in the preview. -QUANTITY_PARSING_PARAMETERS = ( - "complexNumbers", - "convention", - "elementary_functions", - "rationalise", - "reserved_keywords", - "simplify", - "strict_syntax", - "strictness", -) - - -def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): - 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}), - "rtol": float(params.get("rtol", 0)), - "atol": float(params.get("atol", 0)), - } - - -# 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) -default_parameters.update( - { - "physical_quantity": True, - "strictness": "natural", - "units_string": "SI common imperial", - } -) - - -default_criteria = ["response matches answer"] - - -context = { - "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, -} diff --git a/app/context/physical_quantity/__init__.py b/app/context/physical_quantity/__init__.py new file mode 100644 index 0000000..fe77019 --- /dev/null +++ b/app/context/physical_quantity/__init__.py @@ -0,0 +1,90 @@ +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 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.types import GraphTemplate, Parameters +from app.preview.physical_quantity import preview_function + + +def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str: + return preprocess_quantity(expr, QuantityParams.from_dict(parameters), name=name) + + +graph_templates: dict[str, GraphTemplate] = { + "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: 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) + graphs.update({label: graph_template(criterion, parameters_dict)}) + return graphs + + +# The evaluation parameters that affect how quantities are read. Other parameters +# (e.g. `symbols`, `units_string` and `is_latex`) have never applied when reading +# the response and answer as quantities during evaluation, only in the preview. +QUANTITY_PARSING_PARAMETERS = ( + "complexNumbers", + "convention", + "elementary_functions", + "rationalise", + "reserved_keywords", + "simplify", + "strict_syntax", + "strictness", +) + + +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}), + "rtol": float(params.get("rtol", 0)), + "atol": float(params.get("atol", 0)), + } + + +# 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, + "strictness": "natural", + "units_string": "SI common imperial", + } +) + + +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, +} diff --git a/app/context/physical_quantity/comparison.py b/app/context/physical_quantity/comparison.py new file mode 100644 index 0000000..5f3daf5 --- /dev/null +++ b/app/context/physical_quantity/comparison.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import Callable + +from sympy import Basic, Symbol + +from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.slr_parsing import ExprNode + +from app.context.operators import COMPARISON_OPERATORS +from app.types import Parameters, Substitutions + +NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER') + + +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() + 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: 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, Substitutions], bool]: + comparison = COMPARISON_OPERATORS[comparison_operator] + + 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: Substitutions) -> tuple[bool | None, float | None]: + expr0, expr1 = _substitute(lhs, rhs, substitutions) + if expr0.cancel().simplify().simplify() != 0: + ratio = (expr0/expr1).cancel().simplify() + result = ratio.is_constant() + elif expr0.cancel().simplify().simplify() != 0: + ratio = (expr1/expr0).cancel().simplify() + result = ratio.is_constant() + else: + result = False + if result is True: + ratio = float(ratio) + 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..677bce4 --- /dev/null +++ b/app/context/physical_quantity/criteria_parser.py @@ -0,0 +1,52 @@ +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/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..89ddc7c --- /dev/null +++ b/app/context/physical_quantity/graphs/match.py @@ -0,0 +1,277 @@ +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 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.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators + +is_equal = comparison_function("=") + + +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"] + 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: 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): + 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: 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 + # 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: 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: 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 + + 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: Parameters, 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] + 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: Parameters, 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] + + 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) + # `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 is not None 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: 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?", + 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: 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) + + if label is None: + label = criterion.content_string() + + inputs = criterion.children + lhs_string, rhs_string, lhs, rhs = parse_criterion_sides(criterion, parameters) + + _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..5779267 --- /dev/null +++ b/app/context/physical_quantity/graphs/order.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any + +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.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 + + +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: + label = criterion.content_string() + + 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] + compare = comparison_function(comparison_operator) + + def compare_evaluate(_response: Any) -> ReachedCriteria: + 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: 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: 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: 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: 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 new file mode 100644 index 0000000..ce87184 --- /dev/null +++ b/app/context/physical_quantity/parsing.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from lambdafeedback.expression_parsing import parse_expression +from lambdafeedback.units import parse_quantity as parse_physical_quantity + +from app.types import Parameters, QuantityDict +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: + 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 diff --git a/app/context/symbolic.py b/app/context/symbolic.py deleted file mode 100644 index 04beb90..0000000 --- a/app/context/symbolic.py +++ /dev/null @@ -1,883 +0,0 @@ -from sympy import Add, Pow, Mul, Equality, pi, im, I, N -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 ..symbolic_preview import preview_function -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 - - -default_parameters = { - "complexNumbers": False, - "convention": "equal_precedence", - "elementary_functions": False, - "strict_syntax": False, - "multiple_answers_criteria": "all", -} - - -def expression_preprocess(name, expr, parameters): - return preprocess_expression(expr, ExpressionParams.from_dict(parameters), name=name) - - -def expression_parse(name, expr, parameters, evaluation_result): - expressions = parse_expressions(expr, parameters) - return expressions[0] if len(expressions) == 1 else set(expressions) - - -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()) - - # 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 - - 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): - 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 - } - - 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"] - ans = parameters_dict["reserved_expressions"]["answer"] - - if (not isinstance(res, Equality)) and isinstance(ans, Equality): - return { - label+"_EXPRESSION_NOT_EQUALITY": None - } - - if isinstance(res, Equality) and (not isinstance(ans, Equality)): - return { - label+"_EQUALITY_NOT_EXPRESSION": None - } - - # TODO: Remove when criteria for checking proportionality is implemented - if isinstance(res, Equality) and isinstance(ans, Equality): - if (res.args[0]-res.args[1]).simplify() == 0: - symbols_in_equality_ratio = (ans.args[0]-ans.args[1]).simplify().free_symbols - elif (ans.args[0]-ans.args[1]).simplify() == 0: - symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols - else: - symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) - if result is True: - return { - label+"_TRUE": None - } - else: - return { - label+"_FALSE": None - } - - 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 - if result is True: - return { - label+"_SAME_SYMBOLS"+"_TRUE": None - } - else: - return { - 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]) - - 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") - ) - graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ANSWER", END.label) - 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 - ) - graph.attach_new_criterion( - label, - label+"_UNKNOWN", - 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( - label, - label+"_TRUE", - 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( - label, - label+"_FALSE", - 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( - label, - label+"_EXPRESSION_NOT_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") - ) - graph.attach(label+"_EXPRESSION_NOT_EQUALITY", END.label) - graph.attach_new_criterion( - label, - label+"_EQUALITY_NOT_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") - ) - graph.attach(label+"_EQUALITY_NOT_EXPRESSION", END.label) - 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 - ) - graph.attach_new_criterion( - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_TRUE", - 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( - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_FALSE", - 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( - label, - label+"_FALSE", - 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( - label, - label+"_UNKNOWN", - 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 - - -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) - if result is True: - return { - label+"_TRUE": None - } - else: - return { - label+"_FALSE": None - } - 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) - ) - graph.attach_new_criterion( - label, - label+"_TRUE", - 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", - 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 - ) - graph.attach_new_criterion( - label+"_IDENTIFY_REASON", - label+"_UNKNOWN", - 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 = { - 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]: - graph.attach_new_criterion( - label+"_GET_CANDIDATES_"+group_label, - label+"_RESPONSE_CANDIDATES_"+group_label, - summary="response = "+str(value), - details="Response candidates: "+expressions_string - ) - graph.attach( - label+"_RESPONSE_CANDIDATES_"+group_label, - END.label - ) - 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) - 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 - } - - 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( - label, - label+"_TRUE", - 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( - label, - label+"_FALSE", - 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( - label, - label+"_UNKNOWN", - summary="True", - details=label+" is false.", - feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") - ) - graph.attach(label+"_UNKNOWN", END.label) - return graph - - -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) - for evaluation in graph.evaluations.values(): - if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END_LABEL - graphs.update({label: graph}) - return graphs - - -def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): - return SympyParsingConfig.from_params(ExpressionParams.from_dict(params), unsplittable_symbols, symbol_assumptions) - - -context = { - "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, -} diff --git a/app/context/symbolic/__init__.py b/app/context/symbolic/__init__.py new file mode 100644 index 0000000..30dfd71 --- /dev/null +++ b/app/context/symbolic/__init__.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Iterable +from copy import deepcopy +from typing import Any + +from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser +from lambdafeedback.expression_parsing import ( + ExpressionParams, + SympyParsingConfig, + parse_expressions, + preprocess_expression, +) + +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.result import EvaluationResult +from app.types import Expression, GraphTemplate, Parameters +from app.preview.symbolic import preview_function + + +default_parameters = deepcopy(base_default_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: str, expr: str, parameters: SympyParsingConfig, evaluation_result: EvaluationResult) -> Expression: + expressions = parse_expressions(expr, parameters) + return expressions[0] if len(expressions) == 1 else set(expressions) + + +default_criteria = ["response = answer"] + + +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: Parameters) -> dict[str, CriteriaGraph]: + graphs = dict() + 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)}) + return graphs + + +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: 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, +} diff --git a/app/context/symbolic/comparison.py b/app/context/symbolic/comparison.py new file mode 100644 index 0000000..72945b2 --- /dev/null +++ b/app/context/symbolic/comparison.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterable + +from sympy import Basic, Equality, im, I +from sympy import re as real_part + +from lambdafeedback.expression_parsing import SympyParsingConfig, parse_expression +from lambdafeedback.slr_parsing import ExprNode + +from app.context.operators import COMPARISON_OPERATORS +from app.context.symbolic.numerical import compare_numerically +from app.types import Parameters, SubstitutionList, Substitutions + + +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, + extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(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 + + +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) + 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) + else: + raise Exception(label) + return result + + +def create_expressions_for_comparison( + 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()) + 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: 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 = { + "=": 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: 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: + result = None + return result + + +def check_equality( + 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): + 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. + # 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 + + +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: 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: + 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: 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/__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..26b0796 --- /dev/null +++ b/app/context/symbolic/graphs/equivalence.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from typing import Any + +from sympy import Equality + +from lambdafeedback.criteria import CriteriaGraph, Evaluate, OutputNode, ReachedCriteria +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.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.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators + + +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"] + + 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): + 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_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) + 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(_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) + 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: 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, + 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: 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: Any) -> ReachedCriteria: + 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: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: + 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) + 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..7e4b172 --- /dev/null +++ b/app/context/symbolic/graphs/generic.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +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.symbolic.comparison import check_criterion +from app.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: + feedback_string_generator_inputs = {'criterion': criterion} + label = criterion.content_string() + + 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) + 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/graphs/set_equivalence.py b/app/context/symbolic/graphs/set_equivalence.py new file mode 100644 index 0000000..66d5c5d --- /dev/null +++ b/app/context/symbolic/graphs/set_equivalence.py @@ -0,0 +1,114 @@ +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 app.context.graph_helpers import attach_terminal_criterion +from app.context.symbolic.comparison import check_equality +from app.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]: + """ + 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): + return True, response, set([answer]) + elif isinstance(answer, set): + return True, set([response]), answer + return False, response, answer + + +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: 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: + 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") + ) + 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") + ) + 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") + ) + 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") + ) diff --git a/app/context/symbolic/graphs/substitution.py b/app/context/symbolic/graphs/substitution.py new file mode 100644 index 0000000..85d6c21 --- /dev/null +++ b/app/context/symbolic/graphs/substitution.py @@ -0,0 +1,194 @@ +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 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.types import Parameters, Substitutions +from app.feedback.symbolic import feedback_generators as symbolic_feedback_string_generators + + +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": + 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 + + +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": { + "variations": one_addition_to_subtraction(expression_to_vary), + "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.", + }, + "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.", + } + } + + +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: 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. + """ + 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() + 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: 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: Any) -> ReachedCriteria: + 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(_response: Any) -> ReachedCriteria: + 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"], + 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(), + 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: 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]) + local_subs = parse_where_substitutions(criterion.children[1], parsing_params) + if label is None: + label = criterion.content_string() + + def expression_check(_response: Any) -> ReachedCriteria: + 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 "+subs_string+".", + evaluate=expression_check + ) + attach_terminal_criterion( + graph, + label, + label+"_TRUE", + END, + 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+".", + 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, 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..d8aae9f --- /dev/null +++ b/app/context/symbolic/graphs/syntactical_form.py @@ -0,0 +1,116 @@ +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 app.context.graph_helpers import outcome_tag +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 + + +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: + 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: 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: 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: 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( + 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: ExprNode, parameters_dict: Parameters, label: str | None = None) -> CriteriaGraph: + 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/numerical.py b/app/context/symbolic/numerical.py new file mode 100644 index 0000000..684eb84 --- /dev/null +++ b/app/context/symbolic/numerical.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from sympy import Basic, pi, N + +from app.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. + + 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: Basic) -> Basic: + 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/symbolic/variations.py b/app/context/symbolic/variations.py new file mode 100644 index 0000000..659c8da --- /dev/null +++ b/app/context/symbolic/variations.py @@ -0,0 +1,65 @@ +"""Variations of an expression with one operation changed, used to identify how an incorrect response was computed.""" + +from __future__ import annotations + +from collections.abc import Callable + +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() + 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: 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: + 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: 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: 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] + + def multiplication_to_addition(node: Basic, k: int) -> Basic: + 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: 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/docs/dev.md b/app/docs/dev.md index e3782fd..951100f 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 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. @@ -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. @@ -206,9 +208,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 (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 @@ -234,6 +236,12 @@ 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. + +# Imports +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`. -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 +# 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 diff --git a/app/evaluation.py b/app/evaluation.py index 8b7f4f4..f9fc812 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,5 +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, @@ -7,98 +13,75 @@ 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 collections.abc import Mapping +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 +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." } -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}) -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: 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 = [] + + 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] - 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) + 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: 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: + 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): + +def determine_context(parameters: Parameters) -> dict[str, Any]: if parameters.get("physical_quantity", False) is True: context = deepcopy(quantity_context) 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 -def parse_reserved_expressions(reserved_expressions, parameters, result): +def parse_reserved_expressions( + reserved_expressions: dict[str, dict[str, str]], parameters: Parameters, result: EvaluationResult +) -> tuple[bool, FrozenValuesDictionary]: """ Input: reserved_expressions: dictionary with the following format @@ -164,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: Parameters) -> str: criteria = parameters.get("criteria", None) if criteria is None: criteria = ",".join(parameters["context"]["default_criteria"]) @@ -173,14 +156,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: str) -> list[str]: + """Splits `criteria_string` on the commas that are not inside brackets, and strips each criterion.""" criteria_string_list = [] delims = [ ("(", ")"), @@ -200,8 +177,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: SLRParser, parameters: Parameters) -> FrozenValuesDictionary: + preprocess = parameters["context"]["expression_preprocess"] + original_criteria_string = get_criteria_string(parameters) + try: + 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 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 +199,17 @@ def create_criteria_dict(criteria_parser, parsing_params): return criteria_parsed -def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): +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: Mapping[str, CriteriaGraph], evaluation_parameters: Parameters) -> None: # 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 +231,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: dict[str, Any]) -> None: + """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: 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. + """ parameters = deepcopy(params) reserved_expressions_strings = { @@ -298,25 +287,35 @@ 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: str, strict_syntax: bool) -> list[str]: + """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: 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: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]] +) -> None: + """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,18 +330,18 @@ 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: Parameters, reserved_expressions: Mapping[str, Mapping[str, Any]], criteria: Mapping[str, Any], evaluation_result: EvaluationResult +) -> FrozenValuesDictionary: + 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"), "numerical": parameters.get("numerical", False), "atol": parameters.get("atol", 0), @@ -351,9 +350,42 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di } ) + +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: + 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) + 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) diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py index a29f356..d5eac65 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. """ @@ -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..2033838 100644 --- a/app/feedback/physical_quantity.py +++ b/app/feedback/physical_quantity.py @@ -1,4 +1,10 @@ -feedback_string_generators = dict() +from __future__ import annotations + +from lambdafeedback.units import RevertedUnit + +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] @@ -22,7 +28,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..53baa24 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -1,17 +1,11 @@ -# 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 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: 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.", @@ -51,7 +45,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 +60,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 new file mode 100644 index 0000000..bfc22a6 --- /dev/null +++ b/app/frozen_dict.py @@ -0,0 +1,20 @@ +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/preview.py b/app/preview/__init__.py similarity index 73% rename from app/preview.py rename to app/preview/__init__.py index 0dca3cd..83179fa 100644 --- a/app/preview.py +++ b/app/preview/__init__.py @@ -1,12 +1,11 @@ -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 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/common.py b/app/preview/common.py new file mode 100644 index 0000000..60f7549 --- /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 app.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 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..6ff1e99 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 app.preview.common import Preview, Result def fix_exponents(response: str) -> str: diff --git a/app/symbolic_preview.py b/app/preview/symbolic.py similarity index 84% rename from app/symbolic_preview.py rename to app/preview/symbolic.py index 350a4ca..39214fa 100644 --- a/app/symbolic_preview.py +++ b/app/preview/symbolic.py @@ -8,9 +8,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, NotRequired, TypedDict - -from sympy import Basic +from typing import Any from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, @@ -27,36 +25,16 @@ 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 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/result.py b/app/result.py index 081d837..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,29 +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 get_feedback(self, tag): - return [text for (t, text) in self._feedback if t == tag] - - 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: @@ -41,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/app/evaluation_test.py b/app/tests/evaluation_test.py similarity index 85% rename from app/evaluation_test.py rename to app/tests/evaluation_test.py index 1239a68..43d745d 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 app.evaluation import evaluation_function +from app.preview import preview_function class TestEvaluationFunction(): @@ -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 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 392ce81..d5836cd 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -2,8 +2,8 @@ import pytest -from ..context.physical_quantity_preview 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/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..bdde208 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 app.preview import preview_function class TestPreviewFunction(): diff --git a/app/types.py b/app/types.py new file mode 100644 index 0000000..92859a5 --- /dev/null +++ b/app/types.py @@ -0,0 +1,33 @@ +"""Type aliases shared across the evaluation function.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import Any, TypeAlias + +from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator +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`, 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 new file mode 100644 index 0000000..ba6aff9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,11 @@ +[mypy] +python_version = 3.12 +files = app +exclude = ^app/tests/ +disallow_untyped_defs = True +warn_unused_ignores = True +warn_redundant_casts = True + +# sympy ships without type information +[mypy-sympy.*] +ignore_missing_imports = True 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