diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..8376e0c
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,37 @@
+ARG PYTHON_VERSION=3.12
+FROM python:${PYTHON_VERSION}-slim
+
+# Matches the Python version used by GitHub Actions (test-lint.yml, staging-deploy.yml)
+# and the production image (app/Dockerfile), so tests behave the same locally.
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git \
+ build-essential \
+ curl \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+# Node.js, required to run the Claude Code CLI
+RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN npm install -g @anthropic-ai/claude-code
+
+# Claude Code refuses --dangerously-skip-permissions as root unless it knows
+# it's already inside an isolated sandbox (this container).
+ENV IS_SANDBOX=1
+
+# Run `claude` in this container with permission prompts bypassed by default,
+# since the container itself (not the CLI) is the safety boundary here. This
+# alias only affects the container image, never the host's `claude` config.
+RUN echo "alias claude='claude --dangerously-skip-permissions'" >> /etc/bash.bashrc
+
+WORKDIR /workspace
+
+COPY app/requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir --upgrade pip \
+ && pip install --no-cache-dir -r /tmp/requirements.txt \
+ && pip install --no-cache-dir pytest flake8
+
+ENV PYTHONPATH=/workspace
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..8b4515f
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,37 @@
+{
+ "name": "compareExpressions",
+ "build": {
+ "dockerfile": "Dockerfile",
+ "context": ".."
+ },
+ "workspaceFolder": "/workspace",
+ "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
+
+ "mounts": [
+ "source=${localEnv:HOME}/.claude,target=/root/.claude,type=bind,consistency=cached"
+ ],
+
+ "containerEnv": {
+ "PYTHONPATH": "/workspace",
+ "IS_SANDBOX": "1"
+ },
+
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-python.python",
+ "ms-python.flake8",
+ "anthropic.claude-code"
+ ],
+ "settings": {
+ "python.defaultInterpreterPath": "/usr/local/bin/python",
+ "python.testing.pytestEnabled": true,
+ "python.testing.pytestArgs": []
+ }
+ }
+ },
+
+ "postCreateCommand": "pip install --no-cache-dir -r app/requirements.txt",
+
+ "remoteUser": "root"
+}
diff --git a/.gitignore b/.gitignore
index 14885ce..fc8ed43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -137,3 +137,18 @@ app/benchmark.py
app/profiling_examples.py
.idea/
+
+# Extracted packages live here as their own separate git repo
+/compareExpressions-packages/
+
+# OS / editor / local tool files
+.DS_Store
+.vscode/
+.junie/
+.mcp.json
+
+# Local virtual environments (e.g. .venv38)
+.venv*/
+
+# Test reports written by CI
+reports/
diff --git a/README.md b/README.md
old mode 100755
new mode 100644
index c9040e9..e8b0029
--- a/README.md
+++ b/README.md
@@ -20,15 +20,15 @@ This version is specifically for python, however the ultimate goal is to make si
- [Production API Gateway Integrations](https://eu-west-2.console.aws.amazon.com/apigateway/main/develop/integrations/attach?api=cttolq2oph&integration=qpbgva8®ion=eu-west-2&routes=0xsoy4q)
4. Merge commits into the default branch
- - This will trigger the `test-and-deploy.yml` workflow, which will build the docker image, push it to a shared ECR repository, then call the backend `grading-function/ensure` route to build the necessary infrastructure to make the function available from the client app.
+ - This will trigger the `staging-deploy.yml` workflow (and `production-deploy.yml` on release), which will build the docker image, push it to a shared ECR repository, then call the backend `grading-function/ensure` route to build the necessary infrastructure to make the function available from the client app.
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
- - Edit the `app/evaluation_tests.py` file to add tests which get run:
- - Every time you commit to this repo, before the image is built and deployed
- - Whenever the `healthcheck` command is supplied to the deployed function
- - Edit the `app/docs.md` file to reflect your changes. This file is 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!
+ - Add pytest tests to `app/evaluation_test.py`, `app/preview_test.py` or `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!
---
@@ -36,6 +36,10 @@ This version is specifically for python, however the ultimate goal is to make si
The function is built on top of a custom base layer, [BaseEvaluationFunctionLayer](https://github.com/lambda-feedback/BaseEvalutionFunctionLayer), which tools, tests and schema checking relevant to all evaluation functions.
+### Packages
+
+Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages) repository (currently the `v0.3.0-rc.2` release candidate), installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout.
+
### Docker & Amazon Web Services (AWS)
The grading scripts are hosted AWS Lambda, using containers to run a docker image of the app. Docker is a popular tool in software development that allows programs to be hosted on any machine by bundling all its requirements and dependencies into a single file called an **image**.
@@ -57,7 +61,7 @@ Once the code passes all these tests, it will then be uploaded to AWS and will b
## Pre-requisites
Although all programming can be done through the GitHub interface, it is recommended you do this locally on your machine. To do this, you must have installed:
-- Python 3.8 or higher.
+- Python 3.12 (the version used by CI and the production image).
- GitHub Desktop or the `git` CLI.
diff --git a/__init__.py b/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/app/.dockerignore b/app/.dockerignore
new file mode 100644
index 0000000..e6a0295
--- /dev/null
+++ b/app/.dockerignore
@@ -0,0 +1,12 @@
+# Build files
+Dockerfile
+.dockerignore
+
+# The base image provides the app package's __init__.py and handler
+__init__.py
+
+# Local and generated files
+**/__pycache__
+**/*.py[cod]
+**/.DS_Store
+**/.pytest_cache
diff --git a/app/Dockerfile b/app/Dockerfile
index 22e2aee..a28cf7c 100755
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -1,6 +1,4 @@
-# Base image that bundles AWS Lambda Python 3.8 image with some middleware functions
-# FROM base-eval-tmp
-# FROM rabidsheep55/python-base-eval-layer
+# Base image that bundles the AWS Lambda Python image with the lambda-feedback middleware (app.handler)
FROM ghcr.io/lambda-feedback/baseevalutionfunctionlayer:main-3.12
RUN dnf install -y git findutils && dnf clean all
@@ -13,46 +11,11 @@ ENV PYTHONPATH=/app
COPY requirements.txt .
RUN pip3 install -r requirements.txt
-# Copy main scripts
-COPY evaluation.py ./app/
-COPY evaluation_test.py ./app/
-COPY preview.py ./app/
-COPY preview_test.py ./app/
-
-# Copy contexts
-COPY context/physical_quantity.py ./app/context/
-COPY context/symbolic.py ./app/context/
-
-# Copy feedback messages
-COPY feedback/physical_quantity.py ./app/feedback/
-COPY feedback/symbolic.py ./app/feedback/
-
-# Copy preview implementations
-COPY preview_implementations/physical_quantity_preview.py ./app/preview_implementations/
-COPY preview_implementations/symbolic_preview.py ./app/preview_implementations/
-
-# Copy tests
-COPY tests/example_test.py ./app/tests/
-COPY tests/physical_quantity_evaluation_test.py ./app/tests/
-COPY tests/physical_quantity_preview_test.py ./app/tests/
-COPY tests/slr_quantity_test.py ./app/tests/
-COPY tests/symbolic_evaluation_test.py ./app/tests/
-COPY tests/symbolic_preview_test.py ./app/tests/
-
-# Copy utility code
-COPY utility/criteria_graph_utilities.py ./app/utility/
-COPY utility/criteria_parsing.py ./app/utility/
-COPY utility/evaluation_result_utilities.py ./app/utility/
-COPY utility/expression_utilities.py ./app/utility/
-COPY utility/physical_quantity_utilities.py ./app/utility/
-COPY utility/preview_utilities.py ./app/utility/
-COPY utility/slr_parsing_utilities.py ./app/utility/
-COPY utility/syntactical_comparison_utilities.py ./app/utility/
-COPY utility/unit_system_conversions.py ./app/utility/
-
-# Copy Documentation
-COPY docs/dev.md ./app/docs/dev.md
-COPY docs/user.md ./app/docs/user.md
+# The healthcheck command runs the pytest suite (see evaluation_tests.py)
+RUN pip3 install pytest
+
+# Copy the evaluation function source, tests and docs (exclusions in .dockerignore)
+COPY . ./app/
# Set permissions so files and directories can be accessed on AWS
RUN chmod 644 $(find . -type f)
diff --git a/app/benchmarking.py b/app/benchmarking.py
deleted file mode 100644
index 3bff3f9..0000000
--- a/app/benchmarking.py
+++ /dev/null
@@ -1,57 +0,0 @@
-benchmarks = [
- {
- "response": "2a",
- "answer": "a",
- "params": {
- "strict_syntax": False,
- "elementary_functions": True,
- "criteria": "response/answer=2",
- }
- },
- {
- "response": "2*x**2 = 10*y**2+20",
- "answer": "x**2-5*y**2-10=0",
- "params": {"strict_syntax": False}
- },
- {
- "response": "1.24 mile/hour",
- "answer": "1.24 mile/hour",
- "params": {
- "strict_syntax": False,
- "elementary_functions": True,
- "physical_quantity": True,
- }
- },
- {
- "response": "sin(x)+2",
- "answer": "sin(x)",
- "params": {
- "strict_syntax": False,
- "elementary_functions": True,
- "criteria": "Derivative(response,x)=cos(x)",
- }
- },
- {
- "response": "cos(x)**2 + sin(x)**2 + y",
- "answer": "y + 1",
- "params": {"strict_syntax": False}
- },
- {
- "response": "log(2)/2+I*(3*pi/4 plus_minus 2*n*pi)",
- "answer": "log(2)/2+I*(3*pi/4 plus_minus 2*n*pi)",
- "params": {
- "strict_syntax": False,
- "elementary_functions": True,
- }
- },
- {
- "response": "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)",
- "answer": "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)",
- "params": {"strict_syntax": False}
- },
- {
- "response": "-(sin(xy)y+(e^y))/(x(e^y+sin(xy)x))",
- "answer": "-(y*sin(x*y) + e^(y)) / (x*(e^(y) + sin(x*y)))",
- "params": {"strict_syntax": False}
- },
-]
diff --git a/app/conftest.py b/app/conftest.py
new file mode 100644
index 0000000..adb5e82
--- /dev/null
+++ b/app/conftest.py
@@ -0,0 +1,2 @@
+def pytest_configure(config):
+ config.addinivalue_line("markers", "slow: excluded from the deployed healthcheck (see evaluation_tests.py)")
diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py
index 66f8923..b178fc9 100644
--- a/app/context/physical_quantity.py
+++ b/app/context/physical_quantity.py
@@ -2,47 +2,40 @@
# IMPORTS
# -------
-import re
from copy import deepcopy
-from ..utility.physical_quantity_utilities import (
- SLR_quantity_parser,
- SLR_quantity_parsing, expression_preprocess
-)
-from ..preview_implementations.physical_quantity_preview import preview_function
-from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators
-from ..utility.expression_utilities import default_parameters as symbolic_default_parameters
-from ..utility.expression_utilities import (
- substitute_input_symbols,
- create_sympy_parsing_params,
+from lambdafeedback.criteria import CriteriaGraph
+from lambdafeedback.expression_parsing import (
+ ExpressionParams,
+ SympyParsingConfig,
compute_relative_tolerance_from_significant_decimals,
- parse_expression
+ parse_expression,
)
-from ..utility.physical_quantity_utilities import (
- units_sets_dictionary,
- set_of_SI_prefixes,
- set_of_SI_base_unit_dimensions,
- set_of_derived_SI_units_in_SI_base_units,
- set_of_common_units_in_SI,
- set_of_very_common_units_in_SI,
- set_of_imperial_units,
-)
-from ..utility.slr_parsing_utilities import create_node, infix, operate, catch_undefined, SLR_Parser
+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
-from ..utility.criteria_graph_utilities import CriteriaGraph
+
+def expression_preprocess(name, expr, parameters):
+ return preprocess_quantity(expr, QuantityParams.from_dict(parameters), name=name)
def parse_quantity(name, expr, parameters, evaluation_result):
- parser = SLR_quantity_parser(parameters)
- quantity = SLR_quantity_parsing(expr, parameters, parser, name)
- for message in quantity.messages:
- evaluation_result.add_feedback(message)
- if quantity.standard_value is not None:
- standard_value = quantity.standard_value
- else:
+ 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_params)
+ value = parse_expression(quantity.value.content_string(), quantity.parsing_config)
else:
value = None
if quantity.standard_unit is not None:
@@ -50,7 +43,7 @@ def parse_quantity(name, expr, parameters, evaluation_result):
else:
standard_unit = None
if quantity.unit is not None:
- unit = parse_expression(quantity.unit.content_string(), quantity.parsing_params)
+ unit = parse_expression(quantity.unit.content_string(), quantity.parsing_config)
else:
unit = None
quantity_dict = {
@@ -84,19 +77,19 @@ def generate_criteria_parser(reserved_expressions):
}
token_list = [
- (start_symbol, start_symbol),
- (end_symbol, end_symbol),
- (null_symbol, null_symbol),
- (" *BOOL *", "BOOL"),
- (" *QUANTITY *", "QUANTITY"),
- (" *DIMENSION *", "DIMENSION"),
- ("\( *", "START_DELIMITER"),
- (" *\)", "END_DELIMITER"),
- ("response", "QUANTITY"),
- ("answer", "QUANTITY"),
- ("INPUT", "INPUT", catch_undefined),
+ 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 += [(" *"+x+" *", " "+x+" ") for x in criteria_operations]
+ token_list += [TokenPattern(Lexeme((x,), space_before=True, space_after=True), " "+x+" ") for x in criteria_operations]
productions = [
("START", "BOOL", create_node),
@@ -111,7 +104,7 @@ def generate_criteria_parser(reserved_expressions):
("DIMENSION", "dimension(QUANTITY)", operate(1)),
]
- return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol)
+ return SLRParser(token_list, productions, start_symbol, end_symbol, null_symbol)
def comparison_function(comparison_operator):
@@ -136,16 +129,9 @@ def comparison_function_inner(lhs, rhs, substitutions):
def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None):
graph = CriteriaGraph(label)
- END = CriteriaGraph.END
- graph.add_node(END)
+ END = graph.add_end_node()
reserved_expressions = parameters["reserved_expressions"].items()
- parsing_params = deepcopy(parameters["parsing_parameters"])
- 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())
- parsing_params.update({'rtol': rtol})
- parsing_params.update({"simplify": False, "evaluate": False})
+ parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False)
if label is None:
label = criterion.content_string()
@@ -171,7 +157,7 @@ def compare_evaluate(unused_input):
details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.",
evaluate=compare_evaluate
)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_TRUE",
summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.",
@@ -179,7 +165,7 @@ def compare_evaluate(unused_input):
feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE")
)
graph.attach(label+"_TRUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_FALSE",
summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.",
@@ -214,16 +200,15 @@ def less_than_or_equal_node(criterion, parameters, label=None):
def criterion_match_node(criterion, parameters, label=None):
graph = CriteriaGraph(label)
- END = CriteriaGraph.END
- graph.add_node(END)
+ END = graph.add_end_node()
reserved_expressions = parameters["reserved_expressions"].items()
- parsing_params = deepcopy(parameters["parsing_parameters"])
+ 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())
- parsing_params.update({'rtol': rtol})
- parsing_params.update({"simplify": False, "evaluate": False})
if label is None:
label = criterion.content_string()
@@ -277,16 +262,16 @@ def quantity_match(unused_inputs):
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 parsing_params.get('rtol', 0) > 0 or parsing_params.get('atol', 0) > 0:
+ 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 parsing_params.get('rtol', 0) > 0 and (ans != 0):
- value_match = bool(abs(float((ans-res)/ans)) < parsing_params['rtol'])
- elif parsing_params.get('atol', 0) > 0 or (ans == 0):
- answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].converted_unit_factor)
- value_match = bool(abs(float(ans-res)) < parsing_params['atol']*answer_unit_factor)
+ 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)
@@ -349,21 +334,21 @@ def unit_comparison(unused_inputs):
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(
+ 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(
+ 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(
+ graph.attach_new_criterion(
label,
label+"_MISSING_VALUE",
summary="The response is missing a value.",
@@ -371,7 +356,7 @@ def unit_comparison(unused_inputs):
feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE")
)
graph.attach(label+"_MISSING_VALUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_UNEXPECTED_VALUE",
summary="The response is expected only have unit(s), no value.",
@@ -379,7 +364,7 @@ def unit_comparison(unused_inputs):
feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE")
)
graph.attach(label+"_UNEXPECTED_VALUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_MISSING_UNIT",
summary="The response is missing unit(s).",
@@ -387,7 +372,7 @@ def unit_comparison(unused_inputs):
feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT")
)
graph.attach(label+"_MISSING_UNIT", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_UNEXPECTED_UNIT",
summary="The response is expected to be a value without unit(s).",
@@ -395,21 +380,21 @@ def unit_comparison(unused_inputs):
feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT")
)
graph.attach(label+"_UNEXPECTED_UNIT", END.label)
- graph.attach(
+ 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(
+ 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(
+ graph.attach_new_criterion(
label+"_DIMENSION_MATCH",
label+"_DIMENSION_MATCH"+"_FALSE",
summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.",
@@ -418,28 +403,28 @@ def unit_comparison(unused_inputs):
)
graph.attach(label+"_DIMENSION_MATCH"+"_TRUE", END.label)
graph.attach(label+"_DIMENSION_MATCH"+"_FALSE", END.label)
- graph.attach(
+ 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(
+ 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(
+ 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(
+ 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]}.",
@@ -447,7 +432,7 @@ def unit_comparison(unused_inputs):
feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE")
)
graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", END.label)
- graph.attach(
+ 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]}.",
@@ -473,29 +458,33 @@ def feedback_procedure_generator(parameters_dict):
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
+ evaluation.replacement = CriteriaGraph.END_LABEL
graphs.update({label: graph})
return graphs
-def feedback_string_generator(tags, graph, parameters_dict):
- strings = dict()
- for tag in tags:
- # feedback_string = graph.criteria[tag].feedback_string_generator(inputs)
- feedback_string = "PLACEHOLDER"
- if feedback_string is not None:
- strings.update({tag: feedback_string})
- return
+# 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()):
- parsing_parameters = create_sympy_parsing_params(params)
- parsing_parameters.update({
- "strictness": params.get("strictness", "natural"),
+ 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)),
- })
- return parsing_parameters
+ }
# CONSIDER: Move these to separate file so that they can be shared with
@@ -521,6 +510,5 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as
"default_parameters": default_parameters,
"default_criteria": default_criteria,
"feedback_procedure_generator": feedback_procedure_generator,
- "feedback_string_generator": feedback_string_generator,
"parsing_parameters_generator": parsing_parameters_generator,
}
diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py
new file mode 100644
index 0000000..427510e
--- /dev/null
+++ b/app/context/physical_quantity_preview.py
@@ -0,0 +1,91 @@
+"""Previewing a quantity response: LaTeX and SymPy renderings of value and unit."""
+
+from __future__ import annotations
+
+import tokenize
+from collections.abc import Mapping
+from typing import Any
+
+from lambdafeedback.expression_parsing import (
+ SympyParsingConfig,
+ find_matching_parenthesis,
+ parse_expression,
+ parse_latex,
+ sanitise_latex,
+ sympy_to_latex,
+)
+from lambdafeedback.units import (
+ QuantityError,
+ QuantityParams,
+ QuantityParseError,
+ parse_quantity,
+ preprocess_quantity,
+)
+
+from ..symbolic_preview import Preview, Result
+
+
+def fix_exponents(response: str) -> str:
+ """Rewrite LaTeX exponents ``^{...}`` / ``**{...}`` as ``**(...)``.
+
+ Only a brace directly after the exponent operator is unwrapped; other
+ exponents (``x^2``) just get ``**``.
+ """
+ for notation in ("^", "**"):
+ processed = []
+ index = 0
+ while index < len(response):
+ exponent_start = response.find(notation, index)
+ if exponent_start < 0:
+ processed.append(response[index:])
+ break
+ processed.append(response[index:exponent_start] + "**")
+ index = exponent_start + len(notation)
+ if response.startswith("{", index):
+ exponent_end = find_matching_parenthesis(response, index, delimiters=("{", "}"))
+ if exponent_end > 0:
+ processed.append("(" + response[index + 1:exponent_end] + ")")
+ index = exponent_end + 1
+ response = "".join(processed)
+ return response
+
+
+def preview_function(response: str, params: QuantityParams | Mapping[str, Any]) -> Result:
+ """Preview a quantity response as LaTeX and SymPy syntax.
+
+ ``params`` may be :class:`QuantityParams` or evaluation-function
+ parameters. Raises :class:`QuantityParseError` if the response can't be
+ read as a quantity.
+ """
+ params = params if isinstance(params, QuantityParams) else QuantityParams.from_dict(params)
+ if not response:
+ return Result(preview=Preview(latex="", sympy=""))
+
+ try:
+ if params.is_latex:
+ response = fix_exponents(sanitise_latex(response))
+ quantity = parse_quantity(response, params)
+ value, unit = quantity.value, quantity.unit
+ value_latex = value_sympy = ""
+ if value is not None:
+ value_string = parse_latex(value.content_string(), params.symbols, params.simplify)
+ value_expr = parse_expression(value_string, SympyParsingConfig.from_params(params))
+ value_latex = sympy_to_latex(value_expr, params.symbols)
+ value_sympy = str(value_expr)
+ separators = ("~", " ") if value is not None and unit is not None else ("", "")
+ unit_latex = quantity.unit_latex if unit is not None else ""
+ unit_sympy = unit.content_string() if unit is not None else ""
+ latex_out = value_latex + separators[0] + (unit_latex or "")
+ sympy_out = value_sympy + separators[1] + unit_sympy
+ else:
+ preprocessed = preprocess_quantity(response, params, name="response")
+ latex_out = parse_quantity(preprocessed, params).latex
+ sympy_out = response
+ except QuantityError:
+ raise
+ except (SyntaxError, tokenize.TokenError) as e:
+ raise QuantityParseError("Failed to parse Sympy expression") from e
+ except ValueError as e:
+ raise QuantityParseError("Failed to parse LaTeX expression") from e
+
+ return Result(preview=Preview(latex=latex_out, sympy=sympy_out))
diff --git a/app/context/symbolic.py b/app/context/symbolic.py
index 960c989..04beb90 100644
--- a/app/context/symbolic.py
+++ b/app/context/symbolic.py
@@ -1,39 +1,54 @@
-from copy import deepcopy
-from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo
+from sympy import Add, Pow, Mul, Equality, pi, im, I, N
from sympy import re as real_part
-from ..utility.expression_utilities import (
- default_parameters,
+from lambdafeedback.expression_parsing import (
+ ExpressionParams,
+ SympyParsingConfig,
+ generate_arbitrary_number_pattern_matcher,
parse_expression,
- create_sympy_parsing_params,
+ parse_expressions,
preprocess_expression,
)
-from ..preview_implementations.symbolic_preview import preview_function
+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 ..utility.syntactical_comparison_utilities import patterns as syntactical_forms
-from ..utility.syntactical_comparison_utilities import generate_arbitrary_number_pattern_matcher
+from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser
-from ..utility.criteria_parsing import generate_criteria_parser
-from ..utility.criteria_graph_utilities import CriteriaGraph
+
+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(name, expr, parameters)
+ return preprocess_expression(expr, ExpressionParams.from_dict(parameters), name=name)
def expression_parse(name, expr, parameters, evaluation_result):
- return parse_expression(expr, parameters)
+ 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 = deepcopy(parameters_dict["parsing_parameters"])
- parsing_params.update({"simplify": False})
+ parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False)
if label in {"EQUALITY", "WRITTEN_AS"}:
result = check_equality(criterion, parameters_dict)
elif label == "ORDER":
@@ -61,20 +76,14 @@ def check_criterion(criterion, parameters_dict, generate_feedback=True):
def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]):
- parsing_params = deepcopy(parameters_dict["parsing_parameters"])
+ parsing_params = parameters_dict["parsing_parameters"]
reserved_expressions = list(parameters_dict["reserved_expressions"].items())
- parsing_params.update(
- {
- "simplify": False,
- "evaluate": False,
- "unsplittable_symbols": parsing_params["unsplittable_symbols"]+list((parameters_dict["reserved_expressions"].keys())),
- }
- )
+ 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.get("complexNumbers", False):
+ 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):
@@ -345,7 +354,7 @@ def equality_equivalence(unused_input):
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"])
+ result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants)
if result is True:
return {
label+"_TRUE": None
@@ -356,20 +365,14 @@ def equality_equivalence(unused_input):
}
graph = CriteriaGraph(label)
- END = CriteriaGraph.END
- graph.add_node(END)
+ END = graph.add_end_node()
lhs = criterion.children[0].content_string()
rhs = criterion.children[1].content_string()
def same_symbols(unused_input):
- parsing_params = deepcopy(parameters_dict["parsing_parameters"])
+ parsing_params = parameters_dict["parsing_parameters"]
local_substitutions = list(parameters_dict["reserved_expressions"].items())
- parsing_params.update(
- {
- "simplify": False,
- "unsplittable_symbols": parsing_params["unsplittable_symbols"]+list((parameters_dict["reserved_expressions"].keys())),
- }
- )
+ 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
@@ -406,14 +409,14 @@ def same_symbols(unused_input):
details="Checks if "+str(lhs)+"="+str(rhs)+".",
evaluate=set_equivalence
)
- graph.attach(
+ 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(
+ graph.attach_new_criterion(
label,
label+"_MULTIPLE_ANSWER_FAIL_ALL",
summary=str(lhs)+" is not equal to "+str(rhs),
@@ -421,7 +424,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL")
)
graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_MULTIPLE_ANSWER_FAIL_RESPONSE",
summary="Unexpected element in response.",
@@ -429,7 +432,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE")
)
graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_MULTIPLE_ANSWER_FAIL_ANSWER",
summary="Missing element in response",
@@ -444,7 +447,7 @@ def same_symbols(unused_input):
details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".",
evaluate=equality_equivalence
)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_UNKNOWN",
summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs),
@@ -452,7 +455,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN")
)
graph.attach(label+"_UNKNOWN", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_TRUE",
summary=str(lhs)+" is equivalent to "+str(rhs),
@@ -460,7 +463,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT")
)
graph.attach(label+"_TRUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_FALSE",
summary=str(lhs)+" is not equivalent to "+str(rhs),
@@ -468,7 +471,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT")
)
graph.attach(label+"_FALSE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_EXPRESSION_NOT_EQUALITY",
summary=str(lhs)+" is an expression, not an equality.",
@@ -476,7 +479,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY")
)
graph.attach(label+"_EXPRESSION_NOT_EQUALITY", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_EQUALITY_NOT_EXPRESSION",
summary=str(lhs)+" is an equality, not an expression.",
@@ -491,21 +494,21 @@ def same_symbols(unused_input):
details="Checks if "+str(lhs)+"="+str(rhs)+".",
evaluate=mathematical_equivalence
)
- graph.attach(
+ 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(
+ 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(
+ graph.attach_new_criterion(
label+"_SAME_SYMBOLS",
label+"_SAME_SYMBOLS"+"_TRUE",
summary=str(lhs)+" has the same symbols as "+str(rhs),
@@ -513,7 +516,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE")
)
graph.attach(label+"_SAME_SYMBOLS"+"_TRUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label+"_SAME_SYMBOLS",
label+"_SAME_SYMBOLS"+"_FALSE",
summary=str(lhs)+" does not have the same symbols as "+str(rhs),
@@ -521,7 +524,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE")
)
graph.attach(label+"_SAME_SYMBOLS"+"_FALSE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_FALSE",
summary=str(lhs)+"=\\="+str(rhs),
@@ -529,7 +532,7 @@ def same_symbols(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE")
)
graph.attach(label+"_FALSE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_UNKNOWN",
summary="Cannot detrmine if "+str(lhs)+"="+str(rhs),
@@ -548,7 +551,7 @@ 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:
+ 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}
@@ -559,7 +562,7 @@ def find_form(unused_input):
def compare_to_form(string, form_label):
def inner(unused_input):
result = dict()
- if syntactical_forms[form_label]["matcher"](string) is True:
+ if syntactical_forms[form_label].matcher(string) is True:
result.update({label+"_TRUE": None})
else:
result.update({label+"_FALSE": None})
@@ -567,8 +570,7 @@ def inner(unused_input):
return inner
graph = CriteriaGraph(label)
- END = CriteriaGraph.END
- graph.add_node(END)
+ graph.add_end_node()
lhs = criterion.children[0].content_string()
rhs = criterion.children[1].content_string()
@@ -592,14 +594,14 @@ def response_written_as_answer(unused_input):
details=f"Checks if {rhs} is written in a known form.",
evaluate=written_as_known_form(answer, rhs)
)
- graph.attach(
+ 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(
+ graph.attach_new_evaluation(
str(rhs)+"_WRITTEN_AS_UNKNOWN",
label,
summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".",
@@ -607,14 +609,14 @@ def response_written_as_answer(unused_input):
evaluate=response_written_as_answer,
sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"}
)
- graph.attach(
+ 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(
+ graph.attach_new_criterion(
label,
label+"_FALSE",
summary=str(rhs)+" written in the same form as "+str(rhs)+".",
@@ -623,18 +625,18 @@ def response_written_as_answer(unused_input):
)
for form_label in syntactical_forms.keys():
- graph.attach(
+ 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.",
+ 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(
+ 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.",
+ 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}
)
@@ -673,15 +675,14 @@ def expression_check(unused_input):
return expression_check
graph = CriteriaGraph(label)
- END = CriteriaGraph.END
- graph.add_node(END)
+ 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(
+ graph.attach_new_criterion(
label,
label+"_TRUE",
summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]),
@@ -689,7 +690,7 @@ def expression_check(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE")
)
graph.attach(label+"_TRUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_FALSE",
summary="not "+expression.content_string(),
@@ -741,14 +742,14 @@ def identify_reason(unused_input):
label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"})
}
return reasons
- graph.attach(
+ 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(
+ graph.attach_new_criterion(
label+"_IDENTIFY_REASON",
label+"_UNKNOWN",
summary="Unknown reason",
@@ -764,14 +765,14 @@ def get_candidates(unused_input):
return candidates
for (group_label, group_info) in variation_groups.items():
- graph.attach(
+ 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(
+ graph.attach_new_evaluation(
label+"_"+group_label,
label+"_GET_CANDIDATES_"+group_label,
summary="Get candidate responses that satisfy "+expression.content_string(),
@@ -785,7 +786,7 @@ def get_candidates(unused_input):
if group_label != "UNKNOWN":
group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label]
if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]:
- graph.attach(
+ graph.attach_new_criterion(
label+"_GET_CANDIDATES_"+group_label,
label+"_RESPONSE_CANDIDATES_"+group_label,
summary="response = "+str(value),
@@ -819,10 +820,9 @@ def evaluation_node_internal(unused_input):
label = criterion.content_string()
graph = CriteriaGraph(label)
- END = CriteriaGraph.END
- graph.add_node(END)
+ END = graph.add_end_node()
graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_TRUE",
summary="True",
@@ -830,7 +830,7 @@ def evaluation_node_internal(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE")
)
graph.attach(label+"_TRUE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_FALSE",
summary="True",
@@ -838,7 +838,7 @@ def evaluation_node_internal(unused_input):
feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE")
)
graph.attach(label+"_FALSE", END.label)
- graph.attach(
+ graph.attach_new_criterion(
label,
label+"_UNKNOWN",
summary="True",
@@ -862,30 +862,22 @@ def feedback_procedure_generator(parameters_dict):
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
+ evaluation.replacement = CriteriaGraph.END_LABEL
graphs.update({label: graph})
return graphs
-def feedback_string_generator(tags, graph, parameters_dict):
- strings = dict()
- for tag in tags:
- # feedback_string = graph.criteria[tag].feedback_string_generator(inputs)
- feedback_string = "PLACEHOLDER"
- if feedback_string is not None:
- strings.update({tag: feedback_string})
- return
+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": generate_criteria_parser,
- "generate_feedback": generate_criteria_parser,
+ "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,
- "feedback_string_generator": feedback_string_generator,
- "parsing_parameters_generator": create_sympy_parsing_params,
+ "parsing_parameters_generator": parsing_parameters_generator,
}
diff --git a/app/docs/dev.md b/app/docs/dev.md
index 7ada921..e3782fd 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 `utility\expression_utilities.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.py`.
+- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `context\symbolic.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.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.
@@ -206,11 +206,34 @@ 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 can be found in `preview_implementations`.
+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).
**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.
-`E` is treated as `E` in when `is_latex` is enabled. As latex2sympy2 does not parse E correctly. More details can be found ont the PR: https://github.com/lambda-feedback/compareExpressions/pull/235
+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
+
+# Packages
+
+The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.2` release candidate. Each package's `CHANGELOG.md` lists the API changes:
+
+| Package | Import | Used for |
+|---|---|---|
+| `lambdafeedback-slr-parsing` | `lambdafeedback.slr_parsing` | The SLR parser behind the criteria and quantity grammars |
+| `lambdafeedback-criteria` | `lambdafeedback.criteria` | Parsing criteria, criteria graphs, turning reached criteria into feedback |
+| `lambdafeedback-expression-parsing` | `lambdafeedback.expression_parsing` | Preprocessing and parsing expressions with SymPy and LaTeX |
+| `lambdafeedback-units` | `lambdafeedback.units` | Unit data, reading physical quantities, dimensional analysis |
+
+The packages don't contain feedback text. They raise typed exceptions (e.g. `BracketNotationError`) or return plain facts (e.g. `PhysicalQuantity.reverted_units`); the app maps these to a `FeedbackTag` (`feedback/tags.py`), and `feedback/symbolic.py` and `feedback/physical_quantity.py` turn them into text. The "written as" forms (`feedback/syntactical_forms.py`) live in the app for the same reason. `result.py` holds the `EvaluationResult` that collects feedback and serialises the response.
+
+To work on the app and the packages together, install the packages from a local checkout in editable mode instead of from git:
+
+```bash
+pip install -e ../lambda-feedback-packages/slr_parsing -e ../lambda-feedback-packages/criteria \
+ -e ../lambda-feedback-packages/expression_parsing -e ../lambda-feedback-packages/units
+grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin
+```
# Tests
-There are two main groups of tests, evaluation tests and preview tests. The evaluation test can be run by calling `evaluation_tests.py`
\ No newline at end of file
+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 deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total.
\ No newline at end of file
diff --git a/app/evaluation.py b/app/evaluation.py
index 4f86527..8b7f4f4 100644
--- a/app/evaluation.py
+++ b/app/evaluation.py
@@ -1,10 +1,18 @@
from copy import deepcopy
-from .utility.evaluation_result_utilities import EvaluationResult
-from .utility.preview_utilities import parse_latex
+from lambdafeedback.expression_parsing import (
+ AbsoluteValueNotationError,
+ BracketNotationError,
+ ExpressionParams,
+ 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
@@ -13,6 +21,13 @@
}
+def notation_error_feedback(error, original_text):
+ """`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,
@@ -57,11 +72,8 @@ def determine_context(parameters):
reserved_keywords_codes = {"where", "written as", "contains", "proportional to"}
reserved_keywords_aliases = {"plus_minus", "minus_plus"}
- for re in parameters["reserved_expressions_strings"].values():
- reserved_keywords_aliases = reserved_keywords_aliases.union(set(re.keys()))
-
- for value in parameters["reserved_expressions_strings"].values():
- reserved_keywords = reserved_keywords_aliases.union(set(value.keys()))
+ 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:
@@ -75,7 +87,6 @@ def determine_context(parameters):
reserved_keywords_aliases_collisions = []
for keyword in reserved_keywords_aliases:
if keyword in input_symbols_reserved_aliases:
- print("Collision found")
reserved_keywords_aliases_collisions.append(keyword)
if len(reserved_keywords_aliases_collisions) > 0:
if len(reserved_keywords_aliases_collisions) == 1:
@@ -83,8 +94,7 @@ def determine_context(parameters):
else:
raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` are reserved keywords and cannot be used as input symbol alternatives.")
- reserved_keywords = reserved_keywords_codes
- context.update({"reserved_keywords": list(reserved_keywords)})
+ context.update({"reserved_keywords": list(reserved_keywords_codes)})
return context
@@ -114,23 +124,25 @@ def parse_reserved_expressions(reserved_expressions, parameters, result):
reserved_expressions_dict.update({key: FrozenValuesDictionary()})
for (label, expr) in reserved_expressions[key].items():
expr_parsed = None
- preprocess_success, expr, preprocess_feedback = preprocess(key, expr, parameters)
- if preprocess_success is False:
+ original_expr = expr
+ try:
+ expr = preprocess(key, expr, parameters)
+ except (BracketNotationError, AbsoluteValueNotationError) as e:
+ feedback = notation_error_feedback(e, original_expr)
+ expr = e.expression
if key == "learner":
- result.add_feedback(preprocess_feedback)
- preprocess_success = True # Preprocess can only create warnings for responses in this case
+ # Preprocess can only create warnings for responses in this case
+ result.add_feedback(str(feedback.tag), internal_feedback(feedback))
else:
- raise Exception(preprocess_feedback[1], preprocess_feedback[0])
+ raise Exception(internal_feedback(feedback), str(feedback.tag)) from e
reserved_expressions[key][label] = expr
if not isinstance(expr, str):
raise Exception(f"Reserved expression {label} must be given as a string.")
if len(expr.strip()) == 0:
if key == "learner":
result.add_feedback(
- (
- f"RESERVED_EXPRESSION_MISSING_{label}",
- messages["RESERVED_EXPRESSION_MISSING"](label)
- )
+ f"RESERVED_EXPRESSION_MISSING_{label}",
+ messages["RESERVED_EXPRESSION_MISSING"](label)
)
else:
raise Exception(messages["RESERVED_EXPRESSION_MISSING"](label), f"RESERVED_EXPRESSION_MISSING_{label}")
@@ -143,10 +155,8 @@ def parse_reserved_expressions(reserved_expressions, parameters, result):
success = False
if key == "learner":
result.add_feedback(
- (
- f"PARSE_ERROR_{label}",
- symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})
- )
+ f"PARSE_ERROR_{label}",
+ symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})
)
else:
raise Exception(symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})) from e
@@ -165,10 +175,12 @@ def get_criteria_string(parameters):
def create_criteria_dict(criteria_parser, parsing_params):
preprocess = parsing_params["context"]["expression_preprocess"]
- criteria_string = get_criteria_string(parsing_params)
- preprocess_success, criteria_string, preprocess_feedback = preprocess("criteria", criteria_string, parsing_params)
- if preprocess_success is False:
- raise Exception(preprocess_feedback[1], preprocess_feedback[0])
+ original_criteria_string = get_criteria_string(parsing_params)
+ try:
+ criteria_string = preprocess("criteria", original_criteria_string, parsing_params)
+ except (BracketNotationError, AbsoluteValueNotationError) as e:
+ feedback = notation_error_feedback(e, original_criteria_string)
+ raise Exception(internal_feedback(feedback), str(feedback.tag)) from e
criteria_string_list = []
delims = [
("(", ")"),
@@ -205,7 +217,7 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters):
response = evaluation_parameters["reserved_expressions"]["response"]
criteria_feedback = set()
is_correct = True
- custom_feedback = evaluation_parameters.get("custom_feedback",{})
+ custom_feedback = evaluation_parameters.get("custom_feedback", {})
for (criterion_identifier, graph) in criteria_graphs.items():
# TODO: Find better way to identify main criteria for criteria graph
main_criteria = criterion_identifier+"_TRUE"
@@ -213,14 +225,14 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters):
# TODO: Implement way to define completeness of task other than "all main criteria satisfied"
is_correct = is_correct and main_criteria in criteria_feedback
- evaluation_result.add_criteria_graph(criterion_identifier, graph)
+ evaluation_result.criteria_graphs[criterion_identifier] = graph
# Generate feedback strings from found feedback
# NOTE: Feedback strings are generated for each graph due to the
# assumption that some way to return partial feedback
# before script has executed completely will be available
# in the future
- evaluation_result.add_feedback_from_tags(criteria_feedback, graph, custom_feedback=custom_feedback)
+ graph.export_feedback(evaluation_result, criteria_feedback, custom_feedback=custom_feedback)
evaluation_result.is_correct = is_correct
return
@@ -270,7 +282,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di
"reserved_keywords": context["reserved_keywords"]+reserved_expressions_keys,
}
)
- parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys)
+ parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys)
parameters.update(
{
"parsing_parameters": parsing_parameters,
@@ -283,20 +295,20 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di
if parameters.get("is_latex", False):
parameters["reserved_expressions_strings"]["learner"].update(
{
- "response": parse_latex(response, parameters.get("symbols", {}), False, parameters=parameters),
+ "response": parse_latex(response, ExpressionParams.from_dict(parameters).symbols),
}
)
- # FIXME: Move this into expression_utilities
+ # FIXME: Move these notation warnings into lambdafeedback.expression_parsing
if params.get("strict_syntax", False):
if "^" in response:
- evaluation_result.add_feedback(("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict())))
+ evaluation_result.add_feedback("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict()))
if "!" in response:
- evaluation_result.add_feedback(("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict())))
+ evaluation_result.add_feedback("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict()))
if "!!!" in response:
evaluation_result.add_feedback(
- ("NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict())))
+ "NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict()))
reserved_expressions_success, reserved_expressions = parse_reserved_expressions(reserved_expressions_strings, parameters, evaluation_result)
if reserved_expressions_success is False:
@@ -335,7 +347,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di
"numerical": parameters.get("numerical", False),
"atol": parameters.get("atol", 0),
"rtol": parameters.get("rtol", 0),
- "custom_feedback": parameters.get("custom_feedback",{}),
+ "custom_feedback": parameters.get("custom_feedback", {}),
}
)
diff --git a/app/evaluation_test.py b/app/evaluation_test.py
index 6e97e9b..1239a68 100755
--- a/app/evaluation_test.py
+++ b/app/evaluation_test.py
@@ -1,8 +1,6 @@
import pytest
-import os
from .preview import preview_function
-from .utility.preview_utilities import Params
from .evaluation import evaluation_function
@@ -24,15 +22,6 @@ class TestEvaluationFunction():
Use evaluation_function() to call the evaluation function.
"""
- # Import tests that makes sure that mathematical expression comparison works as expected
- from .tests.symbolic_evaluation_test import TestEvaluationFunction as TestSymbolicComparison
-
- # Import tests that makes sure that physical quantities are handled as expected
- from .tests.physical_quantity_evaluation_test import TestEvaluationFunction as TestQuantities
-
- # Import tests that corresponds to examples in documentation and examples module
- from .tests.example_test import TestEvaluationFunction as TestExamples
-
def test_eval_function_can_handle_latex_input(self):
response = r"\sin x + x^{7}"
answer = "sin(x)+x**7"
@@ -96,7 +85,7 @@ def test_physical_qualities_no_tolerance(self):
def test_euler_preview_evaluate(self):
response = "ER_2"
- params = Params(is_latex=True, elementary_functions=False, strict_syntax=False)
+ params = dict(is_latex=True, elementary_functions=False, strict_syntax=False)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -117,7 +106,6 @@ def test_euler_preview_evaluate(self):
result = evaluation_function(response, answer, params)
assert result["is_correct"] is True
-
def test_theta_character_implict_multi_variable(self):
params = {
"strict_syntax": False,
@@ -190,8 +178,6 @@ def test_e_latex(self, response, is_latex, is_correct):
result = evaluation_function(response, answer, params)
assert result["is_correct"] == is_correct
-
-
@pytest.mark.parametrize("params, is_correct", [
({}, True),
({"atol": 0.0, "rtol": 0.0, "strict_syntax": False, "physical_quantity": False, "elementary_functions": False}, True),
@@ -214,7 +200,7 @@ def test_strict_syntax_rejects_implicit_multiplication(self, params, is_correct)
def test_mu_preview_evaluate(self):
response = "10 μA"
- params = Params(is_latex=False, elementary_functions=False, strict_syntax=False, physical_quantity=True)
+ params = dict(is_latex=False, elementary_functions=False, strict_syntax=False, physical_quantity=True)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -257,8 +243,3 @@ def test_laplace_transforms(self, response, is_latex, is_correct):
result = evaluation_function(response, answer, params)
assert result["is_correct"] == is_correct
-
-
-
-if __name__ == "__main__":
- pytest.main(['-xk not slow', '--tb=short', '--durations=10', os.path.abspath(__file__)])
diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py
new file mode 100644
index 0000000..a29f356
--- /dev/null
+++ b/app/evaluation_tests.py
@@ -0,0 +1,21 @@
+"""
+ Entry point for the `healthcheck` command of the deployed function.
+
+ 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
+ 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.
+"""
+import os
+import unittest
+
+import pytest
+
+
+class TestEvaluationFunction(unittest.TestCase):
+
+ def test_pytest_suite(self):
+ # 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 f4320f7..905af92 100644
--- a/app/feedback/physical_quantity.py
+++ b/app/feedback/physical_quantity.py
@@ -20,3 +20,10 @@
"TRUE": "", # TODO: Replace with more specialised messages for different comparisons
"FALSE": "", # TODO: Replace with more specialised messages for different comparisons
}[tag]
+
+
+def reverted_unit_feedback(reverted_unit):
+ """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 366ac07..69c65f4 100644
--- a/app/feedback/symbolic.py
+++ b/app/feedback/symbolic.py
@@ -13,13 +13,13 @@
feedback_generators = dict()
feedback_generators["EQUIVALENCES"] = criteria_equivalences
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.",
+ "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.",
"NO_RESPONSE": "No response submitted.",
"MULTIPLE_ANSWER_FAIL_ALL": "At least one answer or response was incorrect.",
"MULTIPLE_ANSWER_FAIL_RESPONSE": "At least one response was incorrect.",
"MULTIPLE_ANSWER_FAIL_ANSWER": "At least one answer is missing in the response.",
- "PARSE_ERROR": f"`{inputs.get('x','')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.",
+ "PARSE_ERROR": f"`{inputs.get('x', '')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.",
"NOTATION_WARNING_EXPONENT": "Note that `^` cannot be used to denote exponentiation, use `**` instead.",
"NOTATION_WARNING_FACTORIAL": "Note that `!` cannot be used to denote factorial, use `factorial(...)` instead.",
"NOTATION_WARNING_TRIPLE_FACTORIAL": "Note that `!!!` is not supported.",
@@ -77,3 +77,8 @@
"FALSE": None,
"UNKNOWN": None,
}[tag]
+
+
+def internal_feedback(feedback_tag):
+ """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/feedback/syntactical_forms.py b/app/feedback/syntactical_forms.py
new file mode 100644
index 0000000..9bfcb00
--- /dev/null
+++ b/app/feedback/syntactical_forms.py
@@ -0,0 +1,57 @@
+"""Written forms of numbers for "written as" criteria (moved out of lambdafeedback.expression_parsing)."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from lambdafeedback.expression_parsing import (
+ is_complex_number_on_cartesian_form,
+ is_complex_number_on_exponential_form,
+ is_number,
+)
+
+
+@dataclass(frozen=True)
+class SyntacticalPattern:
+ """A written form of numbers, for "written as" criteria.
+
+ ``summary``/``details`` render descriptions from the criterion node (whose
+ two children are the compared expressions) and the parameters.
+ """
+
+ matcher: Callable[[str], bool]
+ name: str
+ summary: Callable[[Any, Any], str]
+ details: Callable[[Any, Any], str]
+
+
+def _both(criterion: Any) -> str:
+ return f"{criterion.children[0].content_string()} and {criterion.children[1].content_string()}"
+
+
+PATTERNS: dict[str, SyntacticalPattern] = {
+ "NUMBER": SyntacticalPattern(
+ matcher=is_number,
+ name="simplified number",
+ summary=lambda criterion, parameters: f"{_both(criterion)} are both numbers written in simplified form.",
+ details=lambda criterion, parameters: f"{_both(criterion)} are both numbers written in simplified form.",
+ ),
+ "CARTESIAN": SyntacticalPattern(
+ matcher=is_complex_number_on_cartesian_form,
+ name="cartesian",
+ summary=lambda criterion, parameters: f"{_both(criterion)} are both complex numbers written in cartesian form",
+ details=lambda criterion, parameters: (
+ f"{_both(criterion)} are both complex numbers written in cartesian form, i.e. $a+bi$."
+ ),
+ ),
+ "EXPONENTIAL": SyntacticalPattern(
+ matcher=is_complex_number_on_exponential_form,
+ name="exponential",
+ summary=lambda criterion, parameters: (
+ f"{_both(criterion)} are both complex numbers written in exponential form"
+ ),
+ details=lambda criterion, parameters: (
+ f"{_both(criterion)} are both complex numbers written in exponential form, i.e. $a exp(bi)$."
+ ),
+ ),
+}
diff --git a/app/feedback/tags.py b/app/feedback/tags.py
new file mode 100644
index 0000000..9bac22e
--- /dev/null
+++ b/app/feedback/tags.py
@@ -0,0 +1,35 @@
+"""Feedback tags raised by the lambdafeedback packages, and this app's own preview logic.
+
+The packages don't carry compareExpressions' feedback text: a
+:class:`FeedbackTag` is the tag plus the inputs needed to render it (see
+``feedback/symbolic.py`` and ``feedback/physical_quantity.py`` for the
+tag -> text mapping).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from enum import StrEnum
+from typing import Any
+
+
+class FeedbackTagName(StrEnum):
+ """Tags emitted while previewing or preprocessing a symbolic response."""
+
+ BRACKET_NOTATION_MISMATCH = "BRACKET_NOTATION_MISMATCH"
+ """Brackets closed with a different kind than opened (inputs: ``x``, the expression)."""
+
+ ABSOLUTE_VALUE_NOTATION_AMBIGUITY = "ABSOLUTE_VALUE_NOTATION_AMBIGUITY"
+ """``|...|`` pairs that could not be matched unambiguously (inputs: ``name``, e.g. ``"response"``)."""
+
+ PARSE_ERROR = "PARSE_ERROR"
+ """The expression could not be parsed (inputs: ``response``)."""
+
+
+@dataclass(frozen=True)
+class FeedbackTag:
+ """A feedback tag and the inputs needed to render its text."""
+
+ tag: str
+ inputs: Mapping[str, Any] = field(default_factory=dict)
diff --git a/app/preview.py b/app/preview.py
index cebefd6..0dca3cd 100644
--- a/app/preview.py
+++ b/app/preview.py
@@ -1,13 +1,12 @@
-from .utility.preview_utilities import (
- Params,
- Result,
-)
+from collections.abc import Mapping
+from typing import Any
-from .preview_implementations.physical_quantity_preview import preview_function as physical_quantity_preview
-from .preview_implementations.symbolic_preview import preview_function as symbolic_preview
+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
-def preview_function(response: str, params: Params) -> Result:
+def preview_function(response: str, params: Mapping[str, Any]) -> Result:
"""
Function used to preview a student response.
---
diff --git a/app/preview_implementations/__init__.py b/app/preview_implementations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/app/preview_implementations/physical_quantity_preview.py b/app/preview_implementations/physical_quantity_preview.py
deleted file mode 100644
index 7653f1a..0000000
--- a/app/preview_implementations/physical_quantity_preview.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import tokenize
-from copy import deepcopy
-
-from ..utility.expression_utilities import (
- find_matching_parenthesis,
- parse_expression,
- SymbolDict,
- sympy_to_latex,
-)
-
-from ..utility.preview_utilities import (
- Params,
- Preview,
- Result,
- parse_latex,
- sanitise_latex,
-)
-
-from ..utility.expression_utilities import default_parameters as symbolic_default_parameters
-from ..utility.physical_quantity_utilities import SLR_quantity_parser as quantity_parser, expression_preprocess
-from ..utility.physical_quantity_utilities import SLR_quantity_parsing as quantity_parsing
-
-# CONSIDER: Move these to separate file so that they can be shared with
-# the physical quantity context (or move preview implementation into context file)
-default_parameters = deepcopy(symbolic_default_parameters)
-default_parameters.update(
- {
- "physical_quantity": True,
- "strictness": "natural",
- "units_string": "SI common imperial",
- }
-)
-
-
-def fix_exponents(response):
- processed_response = []
- exponents_notation = ['^', '**']
- for notation in exponents_notation:
- index = 0
- while index < len(response):
- exponent_start = response.find(notation, index)
- if exponent_start > -1:
- processed_response.append(response[index:exponent_start])
- exponent_start += len(notation)
- processed_response.append("**")
- exponent_end = find_matching_parenthesis(response, exponent_start, delimiters=('{', '}'))
- if exponent_end > 0:
- inside_exponent = '('+response[(exponent_start+len(notation)):exponent_end]+')'
- processed_response.append(inside_exponent)
- index = exponent_end+1
- else:
- index = exponent_start
- else:
- processed_response.append(response[index:])
- break
- response = "".join(processed_response)
- processed_response = []
- return response
-
-
-def preview_function(response: str, params: Params) -> Result:
- """
- Function used to preview a student response.
- ---
- The handler function passes three arguments to preview_function():
-
- - `response` which are the answers provided by the student.
- - `params` which are any extra parameters that may be useful,
- e.g., error tolerances.
-
- The output of this function is what is returned as the API response
- and therefore must be JSON-encodable. It must also conform to the
- response schema.
-
- Any standard python library may be used, as well as any package
- available on pip (provided it is added to requirements.txt).
-
- The way you wish to structure you code (all in this function, or
- split into many) is entirely up to you.
- """
- for (key, value) in default_parameters.items():
- if key not in params.keys():
- params.update({key: value})
- symbols: SymbolDict = params.get("symbols", {})
-
- if not response:
- return Result(preview=Preview(latex="", sympy=""))
-
- latex_out = ""
- sympy_out = ""
-
- parser = quantity_parser(params)
-
- try:
- if params.get("is_latex", False):
- response = sanitise_latex(response)
- response = fix_exponents(response)
- res_parsed = quantity_parsing(response, params, parser, "response")
- value = res_parsed.value
- unit = res_parsed.unit
- value_latex = ""
- if value is not None:
- value_string = parse_latex(value.content_string(), symbols, params.get("simplify", False))
- params.update({"is_latex": False})
- value = parse_expression(value_string, params)
- value_latex = sympy_to_latex(value, symbols)
- separator_latex = ""
- separator_sympy = ""
- if value is not None and unit is not None:
- separator_latex = "~"
- separator_sympy = " "
- unit_latex = res_parsed.unit_latex_string if unit is not None else ""
- latex_out = value_latex+separator_latex+unit_latex
- value_sympy = str(value)
- unit_sympy = res_parsed.unit.content_string() if unit is not None else ""
- sympy_out = value_sympy+separator_sympy+unit_sympy
- else:
- _, res_pre_processed, _ = expression_preprocess("response", response, params)
- res_parsed = quantity_parsing(res_pre_processed, params, parser, "response")
- latex_out = res_parsed.latex_string
- sympy_out = response
-
- except (SyntaxError, tokenize.TokenError) as e:
- raise Exception("Failed to parse Sympy expression") from e
- except ValueError as e:
- raise Exception("Failed to parse LaTeX expression") from e
-
- return Result(preview=Preview(latex=latex_out, sympy=sympy_out))
diff --git a/app/preview_implementations/symbolic_preview.py b/app/preview_implementations/symbolic_preview.py
deleted file mode 100644
index 79e286b..0000000
--- a/app/preview_implementations/symbolic_preview.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from sympy.parsing.sympy_parser import T as parser_transformations
-from ..utility.expression_utilities import (
- default_parameters,
- convert_absolute_notation,
- create_expression_set,
- create_sympy_parsing_params,
- parse_expression,
- substitute_input_symbols,
- SymbolDict,
- sympy_symbols,
- sympy_to_latex,
- preprocess_expression,
-)
-from ..utility.preview_utilities import (
- Params,
- Preview,
- Result,
- parse_latex
-)
-from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators
-
-
-def parse_symbolic(response: str, params):
- symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"]
- response_list_in = create_expression_set(response, params)
- response_list_out = []
- feedback = []
- for response in response_list_in:
- response = substitute_input_symbols([response.strip()], params)[0]
-
- # Converting absolute value notation to a form that SymPy accepts
- response, response_feedback = convert_absolute_notation(response, "response")
- if response_feedback is not None:
- feedback.append(response_feedback)
- response_list_out.append(response)
-
- parsing_params = create_sympy_parsing_params(params)
- parsing_params["extra_transformations"] = parser_transformations[9] # Add conversion of equal signs
- parsing_params["symbol_dict"].update(sympy_symbols(params.get("symbols", {})))
- result_sympy_expression = []
- for response in response_list_out:
- # Safely try to parse answer and response into symbolic expressions
- try:
- if "atol" in params.keys():
- parsing_params.update({"atol": params["atol"]})
- if "rtol" in params.keys():
- parsing_params.update({"rtol": params["rtol"]})
- res = parse_expression(response, parsing_params)
- except Exception as exc:
- raise SyntaxError(symbolic_comparison_internal_messages("PARSE_ERROR")({"response": response})) from exc
- result_sympy_expression.append(res)
-
- return result_sympy_expression, feedback
-
-
-def preview_function(response: str, params: Params) -> Result:
- """
- Function used to preview a student response.
- ---
- The handler function passes three arguments to preview_function():
-
- - `response` which are the answers provided by the student.
- - `params` which are any extra parameters that may be useful,
- e.g., error tolerances.
-
- The output of this function is what is returned as the API response
- and therefore must be JSON-encodable. It must also conform to the
- response schema.
-
- Any standard python library may be used, as well as any package
- available on pip (provided it is added to requirements.txt).
-
- The way you wish to structure you code (all in this function, or
- split into many) is entirely up to you.
- """
- for (key, value) in default_parameters.items():
- if key not in params.keys():
- params.update({key: value})
-
- original_response = response
-
- symbols: SymbolDict = params.get("symbols", {})
-
- if not response:
- return Result(preview=Preview(latex="", sympy=""))
-
- response_list = response.split("=")
- response_latex = []
- response_sympy = []
-
- for response in response_list:
- try:
- if params.get("is_latex", False) is True:
- sympy_out = [parse_latex(response, symbols, params.get("simplify", False))]
- latex_out = [response]
- else:
- params.update({"rationalise": False})
- _, response, _ = preprocess_expression("response", response, params)
- expression_list, _ = parse_symbolic(response, params)
-
- parsing_params = create_sympy_parsing_params(params)
- printing_symbols = dict()
- for key in parsing_params["symbol_dict"].keys():
- if key in symbols.keys():
- printing_symbols.update({key: symbols[key]["latex"]})
-
- latex_out = []
- sympy_out = []
- for expression in expression_list:
- latex_out.append(sympy_to_latex(expression, symbols, settings={"mul_symbol": r" \cdot "}))
-
- sympy_out.append(response)
-
- sympy_out = sorted(sympy_out)
- latex_out = sorted(latex_out)
-
- if len(sympy_out) == 1:
- sympy_out = sympy_out[0]
- sympy_out = str(sympy_out)
-
- if len(latex_out) > 1:
- latex_out = "\\left\\{"+",~".join(latex_out)+"\\right\\}"
- else:
- latex_out = latex_out[0]
-
- except SyntaxError as exc:
- raise ValueError(f"Failed to parse SymPy expression: {original_response}") from exc
- except ValueError as exc:
- raise ValueError(f"Failed to parse LaTeX expression: {original_response}") from exc
-
- response_latex.append(latex_out)
- response_sympy.append(sympy_out)
-
- return Result(preview=Preview(latex="=".join(response_latex), sympy="=".join(response_sympy)))
diff --git a/app/preview_test.py b/app/preview_test.py
index eca9121..ba762d5 100644
--- a/app/preview_test.py
+++ b/app/preview_test.py
@@ -1,7 +1,5 @@
-import os
import pytest
-from .utility.preview_utilities import Params
from .preview import preview_function
@@ -22,17 +20,14 @@ class TestPreviewFunction():
Use preview_function() to check your algorithm works
as it should.
- """
-
- # Import tests that makes sure that mathematical expression comparison works as expected
- from .tests.symbolic_preview_test import TestPreviewFunction as TestSymbolicComparison
- # Import tests that makes sure that physical quantities are handled as expected
- from .tests.physical_quantity_preview_test import TestPreviewFunction as TestQuantityComparison
+ The symbolic preview itself is tested in the lambdafeedback-expression-parsing
+ package. The physical quantity preview is tested in physical_quantity_preview_test.py.
+ """
def test_empty_latex_expression(self):
response = ""
- params = Params(is_latex=True)
+ params = dict(is_latex=True)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -41,7 +36,7 @@ def test_empty_latex_expression(self):
def test_empty_non_latex_expression(self):
response = ""
- params = Params(is_latex=False)
+ params = dict(is_latex=False)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -50,7 +45,7 @@ def test_empty_non_latex_expression(self):
def test_latex_and_sympy_are_returned(self):
response = "x+1"
- params = Params(is_latex=True)
+ params = dict(is_latex=True)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -59,7 +54,7 @@ def test_latex_and_sympy_are_returned(self):
assert "sympy" in preview.keys()
response = "x+1"
- params = Params(is_latex=False)
+ params = dict(is_latex=False)
result = preview_function(response, params)
assert "preview" in result
@@ -69,7 +64,7 @@ def test_latex_and_sympy_are_returned(self):
def test_natural_logarithm_notation(self):
response = "ln(x)"
- params = Params(is_latex=False)
+ params = dict(is_latex=False)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -88,27 +83,27 @@ def test_natural_logarithm_notation(self):
("e * x", False, False, "e \\cdot x", "e * x"),
("E", False, False, "E", "E",),
("E", True, True, "E", "E",),
- ("e", True, False, "e", "E",),
- ("ex", True, False, "ex", "E*x"),
- ("e * x", True, False, "e * x", "E*x"),
+ ("e", True, False, "e", "e",),
+ ("ex", True, False, "ex", "e*x"),
+ ("e * x", True, False, "e * x", "e*x"),
("E", True, False, "E", "E",),
("ER_2", True, False, "ER_2", "E*R_2",),
("exp(1)", False, True, "e^{1}", "exp(1)"),
("e**1", False, True, "e^{1}", "E**1"),
- ("e^{1}", True, True, "e^{1}", "E"),
+ ("e^{1}", True, True, "e^{1}", "e"),
("exp(0)", False, True, "e^{0}", "exp(0)"),
("e**0", False, True, "e^{0}", "E**0"),
("e^{0}", True, True, "e^{0}", "1"),
("exp(2)", False, True, "e^{2}", "exp(2)"),
("e**2", False, True, "e^{2}", "E**2"),
- ("e^{2}", True, True, "e^{2}", "exp(2)"),
+ ("e^{2}", True, True, "e^{2}", "e**2"),
("exp(x)", False, True, "e^{x}", "exp(x)"),
("e**x", False, True, "e^{x}", "E**x"),
- ("e^{x}", True, True, "e^{x}", "exp(x)")
+ ("e^{x}", True, True, "e^{x}", "e**x")
]
)
def test_eulers_number_notation(self, response, is_latex, elementary_functions, response_latex, response_sympy):
- params = Params(is_latex=is_latex, elementary_functions=elementary_functions, strict_syntax=False)
+ params = dict(is_latex=is_latex, elementary_functions=elementary_functions, strict_syntax=False)
result = preview_function(response, params)
assert "preview" in result.keys()
@@ -120,11 +115,11 @@ def test_eulers_number_notation(self, response, is_latex, elementary_functions,
"response, is_latex, response_latex, response_sympy, symbols", [
("e**ea", False, "e^{ea}", "E**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}),
("e**Ea", False, "e^{ea}", "E**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}),
- ("e^{ea}", True, "e^{ea}", "exp(ea)", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}),
+ ("e^{ea}", True, "e^{ea}", "e**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}),
("e^{Ea}", True, "e^{Ea}", "e**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}),
("e**aea", False, "e^{aea}", "E**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}),
("e**aEa", False, "e^{aea}", "E**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}),
- ("e^{aea}", True, "e^{aea}", "exp(aea)", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}),
+ ("e^{aea}", True, "e^{aea}", "e**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}),
("e^{aEa}", True, "e^{aEa}", "e**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}),
]
)
@@ -175,7 +170,7 @@ def test_alias(self, response, is_latex, response_latex, response_sympy, symbols
[
("plus_minus x", False, '\\left\\{- x,~x\\right\\}', "plus_minus x"),
("\\pm x", True, '\\pm x', '{-x, x}'),
- (r"\pm x^{2}+\mp y^{2}", True, "\pm x^{2}+\mp y^{2}", "{-x**2 + y**2, x**2 - y**2}"),
+ (r"\pm x^{2}+\mp y^{2}", True, r"\pm x^{2}+\mp y^{2}", "{-x**2 + y**2, x**2 - y**2}"),
("plus_minus x**2 + minus_plus y**2", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}", "plus_minus x**2 + minus_plus y**2"),
("- minus_plus x^2 minus_plus y^2", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}", "- minus_plus x^2 minus_plus y^2"),
("- minus_plus x^2 - plus_minus y^2", False, r"\left\{- x^{2} - - y^{2},~x^{2} - y^{2}\right\}", "- minus_plus x^2 - plus_minus y^2"),
@@ -199,7 +194,7 @@ def test_using_plus_minus_symbols(self, response, is_latex, response_latex, resp
},
},
}
- params = Params(**params)
+ params = dict(**params)
result = preview_function(response, params)
assert result["preview"]["latex"] == response_latex
assert result["preview"]["sympy"] == response_sympy
@@ -220,7 +215,7 @@ def test_lh_rh_response(self):
},
},
}
- params = Params(**params)
+ params = dict(**params)
result = preview_function("x + y = y + x", params)
assert result["preview"]["latex"] == "x + y=x + y"
assert result["preview"]["sympy"] == "x + y=y + x"
@@ -250,15 +245,15 @@ def test_multi_character_implicit_multi_variable(self):
assert result["preview"]["sympy"] == "a/(bcd)"
result = preview_function(response_implicit_no_bracket, params)
- assert result["preview"]["latex"] =='\\frac{a}{bc \\cdot d}'
+ assert result["preview"]["latex"] == '\\frac{a}{bc \\cdot d}'
assert result["preview"]["sympy"] == "a/bcd"
@pytest.mark.parametrize(
"response, is_latex, latex, sympy", [
("A/s(e**(-a*s)-e**(-b*s))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-a*s)- E**(-b*s))"),
- ("A/s(e**(-as)-e**(-bs))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-a*s)- E**(-bs))"),
- ("(A/s)( e^{-a*s}-e^{-b*s})", True, r"(A/s)( e^{-a*s}-e^{-b*s})", "A*(-exp(-b*s) + exp(-a*s))/s"),
- ("(A/s)( e^{-as}-e^{-bs})", True, r"(A/s)( e^{-as}-e^{-bs})", "A*(-exp(-b*s) + exp(-a*s))/s"),
+ ("A/s(e**(-as)-e**(-bs))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-as)- E**(-bs))"),
+ ("(A/s)( e^{-a*s}-e^{-b*s})", True, r"(A/s)( e^{-a*s}-e^{-b*s})", "A*(-1/e**(b*s) + e**(-a*s))/s"),
+ ("(A/s)( e^{-as}-e^{-bs})", True, r"(A/s)( e^{-as}-e^{-bs})", "A*(-1/e**(b*s) + e**(-a*s))/s"),
]
)
def test_laplace_transforms(self, response, is_latex, latex, sympy):
@@ -275,7 +270,7 @@ def test_laplace_transforms(self, response, is_latex, latex, sympy):
@pytest.mark.parametrize(
"response, is_latex, latex, sympy", [
- ("(x+y)*(x+z)", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)","(x+y)*(x+z)"),
+ ("(x+y)*(x+z)", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)", "(x+y)*(x+z)"),
("(x+y)*x+z", False, "x \\cdot \\left(x + y\\right) + z", "(x+y)*x+z"),
("x+y*(x+z)", False, "x + y \\cdot \\left(x + z\\right)", "x+y*(x+z)"),
("[x+y]*[x+z]", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)", "(x+y)*(x+z)"),
@@ -336,13 +331,15 @@ def test_brackets_rejected_with_strict_syntax(self, response):
preview_function(response, params)
@pytest.mark.parametrize(
- "response, latex, sympy", [
- ("{x+y}*{x+z}", "\\left\\{x + y\\right\\} \\cdot \\left\\{x + z\\right\\}", "{x+y}*{x+z}"),
- ("{x+y}*x+z", "\\left\\{x + y\\right\\} \\cdot x + z", "{x+y}*x+z"),
- ("x+y*{x+z}", "x + y \\cdot \\left\\{x + z\\right\\}", "x+y*{x+z}"),
+ "response", [
+ "{x+y}*{x+z}",
+ "{x+y}*x+z",
+ "x+y*{x+z}",
]
)
- def test_curly_braces_not_converted_with_strict_syntax(self, response, latex, sympy):
+ def test_curly_braces_not_converted_with_strict_syntax(self, response):
+ # With strict syntax `{}` is a set literal, not a bracket, and arithmetic on
+ # sets is rejected (SymPy deprecates it, and evaluating it used to crash).
params = {
"is_latex": False,
"strict_syntax": True,
@@ -350,9 +347,8 @@ def test_curly_braces_not_converted_with_strict_syntax(self, response, latex, sy
"convention": "implicit_higher_precedence",
}
- result = preview_function(response, params)
- assert result["preview"]["latex"] == latex
- assert result["preview"]["sympy"] == sympy
+ with pytest.raises(ValueError):
+ preview_function(response, params)
@pytest.mark.parametrize("strict_syntax", [False, True])
def test_curly_brace_multiple_answers_still_works(self, strict_syntax):
@@ -366,7 +362,3 @@ def test_curly_brace_multiple_answers_still_works(self, strict_syntax):
result = preview_function("{1, 2}", params)
assert result["preview"]["latex"] == "\\left\\{1,~2\\right\\}"
assert result["preview"]["sympy"] == "{1, 2}"
-
-
-if __name__ == "__main__":
- pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)])
diff --git a/app/preview_tests.py b/app/preview_tests.py
new file mode 100644
index 0000000..11a30ca
--- /dev/null
+++ b/app/preview_tests.py
@@ -0,0 +1,9 @@
+"""
+ The base image's healthcheck loads `TestPreviewFunction` from this module.
+ The preview tests are run as part of the pytest suite in `evaluation_tests.py`.
+"""
+import unittest
+
+
+class TestPreviewFunction(unittest.TestCase):
+ pass
diff --git a/app/requirements.txt b/app/requirements.txt
index 1ee4286..94aabae 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -1,6 +1,11 @@
pydot
-typing_extensions
mpmath==1.3.0
sympy==1.14
-antlr4-python3-runtime==4.7.2
-git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2
\ No newline at end of file
+# Parsing, units and criteria packages extracted from this function
+# (https://github.com/lambda-feedback/lambda-feedback-packages), pinned to the
+# v0.3.0-rc.2 release candidate (0.3.0rc2). All four are listed: they depend on
+# each other by name and aren't published on PyPI.
+lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=slr_parsing
+lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=criteria
+lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=expression_parsing
+lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=units
diff --git a/app/result.py b/app/result.py
new file mode 100644
index 0000000..081d837
--- /dev/null
+++ b/app/result.py
@@ -0,0 +1,44 @@
+from lambdafeedback.criteria import CriteriaGraph
+
+
+class EvaluationResult:
+ """
+ Collects the outcome of an evaluation and serialises it into the
+ response returned by `evaluation_function`.
+
+ Satisfies `lambdafeedback.criteria.ResultLike`, so
+ `CriteriaGraph.export_feedback` can add to it.
+ """
+ def __init__(self):
+ 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
+
+ @property
+ def tags(self):
+ return list(dict.fromkeys(tag for (tag, _) in self._feedback))
+
+ def get_feedback(self, tag):
+ return [text for (t, text) in self._feedback if t == tag]
+
+ def add_feedback(self, tag, feedback):
+ """Record `feedback` under `tag`. Blank feedback records the tag without adding any text."""
+ self._feedback.append((tag, feedback or ""))
+
+ def _serialise_feedback(self):
+ 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)
+ if include_test_data is True:
+ out.update(CriteriaGraph.test_data(self.criteria_graphs))
+ if self.latex is not None:
+ out.update(dict(response_latex=self.latex))
+ if self.simplified is not None:
+ simplified = self.simplified
+ if isinstance(simplified, list):
+ simplified = ", ".join(simplified)
+ out.update(dict(response_simplified=simplified))
+ return out
diff --git a/app/symbolic_preview.py b/app/symbolic_preview.py
new file mode 100644
index 0000000..350a4ca
--- /dev/null
+++ b/app/symbolic_preview.py
@@ -0,0 +1,126 @@
+"""Previewing a learner's response: LaTeX and SymPy renderings of what was typed.
+
+Moved here from ``lambdafeedback.expression_parsing``: rendering a
+response for display, and mapping a parse failure to a tag for the learner,
+are app-layer concerns.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, NotRequired, TypedDict
+
+from sympy import Basic
+
+from lambdafeedback.expression_parsing import (
+ AbsoluteValueNotationError,
+ BracketNotationError,
+ ExpressionParams,
+ ExpressionParsingError,
+ SympyParsingConfig,
+ convert_absolute_notation,
+ create_expression_set,
+ parse_expression,
+ parse_latex,
+ preprocess_expression,
+ substitute_input_symbols,
+ 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
+
+
+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]]:
+ """Parse a (preprocessed) response into SymPy expressions, one per expression it stands for.
+
+ Also returns the feedback raised while converting ``|...|`` notation.
+ Raises :class:`ExpressionSyntaxError` (with ``PARSE_ERROR`` feedback)
+ when an expression can't be parsed.
+ """
+ responses = []
+ feedback: list[FeedbackTag] = []
+ for expression in create_expression_set(response, params):
+ expression = substitute_input_symbols(expression.strip(), params)
+ try:
+ expression = convert_absolute_notation(expression, name="response")
+ except AbsoluteValueNotationError as exc:
+ feedback.append(FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": exc.name}))
+ expression = exc.expression
+ responses.append(expression)
+
+ config = SympyParsingConfig.from_params(params, unsplittable_symbols=params.symbols)
+ parsed = []
+ for expression in responses:
+ try:
+ parsed.append(parse_expression(expression, config))
+ except Exception as exc:
+ raise ExpressionSyntaxError(FeedbackTag(FeedbackTagName.PARSE_ERROR, {"response": expression})) from exc
+ return parsed, feedback
+
+
+def preview_function(response: str, params: ExpressionParams | Mapping[str, Any]) -> Result:
+ """Preview a learner's response as LaTeX and SymPy syntax.
+
+ ``params`` may be :class:`ExpressionParams` or evaluation-function
+ parameters. Each side of an ``=`` is previewed separately; a response
+ standing for several expressions (``{a, b}``, ``±``) previews as a set.
+ Raises :class:`ExpressionParsingError` if the response can't be parsed.
+ """
+ params = _as_params(params)
+ if not response:
+ return Result(preview=Preview(latex="", sympy=""))
+
+ latex_parts = []
+ sympy_parts = []
+ for part in response.split("="):
+ try:
+ if params.is_latex:
+ latex_out = [part]
+ sympy_out = [parse_latex(part, params.symbols, params.simplify)]
+ else:
+ symbolic_params = params.replace(rationalise=False)
+ try:
+ preprocessed = preprocess_expression(part, symbolic_params, name="response")
+ except (BracketNotationError, AbsoluteValueNotationError) as exc:
+ preprocessed = exc.expression
+ expressions, _ = parse_symbolic(preprocessed, symbolic_params)
+ latex_out = sorted(
+ sympy_to_latex(expression, params.symbols, settings={"mul_symbol": r" \cdot "})
+ for expression in expressions
+ )
+ sympy_out = [preprocessed]
+ except ExpressionSyntaxError as exc:
+ raise ExpressionParsingError(f"Failed to parse SymPy expression: {response}") from exc
+ except ValueError as exc:
+ raise ExpressionParsingError(f"Failed to parse LaTeX expression: {response}") from exc
+
+ sympy_parts.append(sympy_out[0] if len(sympy_out) == 1 else str(sympy_out))
+ latex_parts.append(latex_out[0] if len(latex_out) == 1 else "\\left\\{" + ",~".join(latex_out) + "\\right\\}")
+
+ return Result(preview=Preview(latex="=".join(latex_parts), sympy="=".join(sympy_parts)))
diff --git a/app/tests/example_test.py b/app/tests/example_test.py
index 4e99325..8cf716e 100644
--- a/app/tests/example_test.py
+++ b/app/tests/example_test.py
@@ -1,20 +1,8 @@
-import os
import pytest
from ..evaluation import evaluation_function
from ..preview import preview_function
-def create_diagram_for_documentation(filename, result):
- for (index, graph) in enumerate(result["criteria_graphs_vis"].values()):
- with open(filename+"_"+str(index)+".md", "w") as f:
- #f.write(r'
\n'+g+'\n\n') - f.write(g+"\n") - #f.write('') - f.write("```\n") class TestEvaluationFunction(): """ @@ -67,6 +55,7 @@ def test_setting_input_symbols_to_be_assumed_positive_to_avoid_issues_with_fract ("+- x**2 + -+ y**2", False, r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), ] ) + @pytest.mark.slow def test_using_plus_minus_symbols(self, response, is_latex, response_latex): answer = "plus_minus x**2 + minus_plus y**2" params = { @@ -90,7 +79,6 @@ def test_using_plus_minus_symbols(self, response, is_latex, response_latex): params.update({"is_latex": True}) # Checking latex output disabled as the function return a few different # variants of the latex in an unpredictable way - # preview = preview_function(response, params)["preview"] # assert preview["latex"] == response_latex result = evaluation_function(response, answer, params) assert result["is_correct"] is True @@ -131,7 +119,7 @@ def test_equalities_in_the_answer_and_response(self, response, response_latex): ("109.12 foot/minute", "2.00 km/h", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), ("0.556 m/s", "0.556 metre/second", r"0.556~\frac{\mathrm{metre}}{\mathrm{second}}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_IDENTICAL"}), ("5.56 dm/s", "0.556 metre/second", r"5.56~\frac{\mathrm{decimetre}}{\mathrm{second}}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), - ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{(-1)}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), + ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{-1}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), ("1.24 mile/hour", "1.24 mile/hour", r"1.24~\frac{\mathrm{mile}}{\mathrm{hour}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_IDENTICAL"}), ("2 km/h", "1.24 mile/hour", r"2~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), # Ideally False but works with base SI units ("109.12 foot/minute", "1.24 mile/hour", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), @@ -154,7 +142,7 @@ def test_checking_the_value_of_a_physical_quantity(self, response, answer, respo assert tags == set(result["tags"]) assert result["is_correct"] == value - def test_checking_the_value_of_a_physical_quantity_and_create_diagram_for_docs(self): + def test_checking_the_value_of_a_physical_quantity_long_and_short_unit_names(self): params = { "strict_syntax": False, "elementary_functions": True, @@ -163,8 +151,7 @@ def test_checking_the_value_of_a_physical_quantity_and_create_diagram_for_docs(s response = "2.00 kilometre/hour" answer = "2.00 km/h" result = evaluation_function(response, answer, params, include_test_data=True) - create_diagram_for_documentation("physical_quantity", result) - assert result["is_correct"] == True + assert result["is_correct"] is True @pytest.mark.parametrize( "res,ans,convention,value", @@ -208,6 +195,7 @@ def test_implicit_multiplication_convention(self, res, ans, convention, value): ) ] ) + @pytest.mark.slow def test_setting_absolute_or_relative_tolerances_for_numerical_comparison(self, answer, atol_response_true, atol_response_false, rtol_response_true, rtol_response_false): params = { "strict_syntax": False, @@ -286,6 +274,7 @@ def test_setting_absolute_or_relative_tolerances_for_numerical_comparison(self, ("cos(x)+2", "sin(x)", r"\cos{\left(x \right)} + 2", "diff(response,x)=cos(x)", False, [], dict()), ] ) + @pytest.mark.slow def test_customizing_comparison(self, response, answer, response_latex, criteria, value, feedback_tags, extra_params): params = { "strict_syntax": False, @@ -330,6 +319,7 @@ def test_using_input_symbols_alternatives(self, response): ("Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)", True), ] ) + @pytest.mark.slow def test_MECH50001_2_24_a(self, response, value): params = { "strict_syntax": False, @@ -596,13 +586,6 @@ def test_custom_comparison_with_criteria_order(self, response, value, tags): } answer = "2*x^2" result = evaluation_function(response, answer, params, include_test_data=True) - create_diagram_for_documentation("custom_comparison_with_criteria_order", result) -# with open("diagrams.html", "w") as f: -# f.write(r''+'\n') -# for g in result["criteria_graphs_vis"].values(): -# print(g) -# f.write('
\n'+g+'\n\n') -# f.write('') assert result["is_correct"] is value assert set(tags) == set(result["tags"]) @@ -673,6 +656,3 @@ def test_criteria_custom_feedback(self, response, answer, criteria, value, feedb assert set(feedback_tags) == set(result["tags"]) for string in custom_feedback.values(): assert string in result["feedback"] - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py deleted file mode 100644 index c743291..0000000 --- a/app/tests/expression_utilities_test.py +++ /dev/null @@ -1,483 +0,0 @@ -import pytest -from sympy import Symbol, sqrt, sin as sympy_sin - -from ..utility.expression_utilities import ( - compute_relative_tolerance_from_significant_decimals, - convert_absolute_notation, - convert_bracket_notation, - convert_unicode_dashes, - create_expression_set, - extract_latex, - find_matching_parenthesis, - has_matching_brackets, - is_multiple_answers_wrapper, - latex_symbols, - preprocess_expression, - protect_elementary_functions_substitutions, - substitute, - substitute_input_symbols, - substitutions_sort_key, - sympy_symbols, - sympy_to_latex, - transform_unicode_greek_symbols, -) - - -class TestConvertUnicodeDashes: - - @pytest.mark.parametrize( - "expr, expected", - [ - # No dashes at all - ("x+y", []), - # ASCII hyphen-minus is not a unicode dash — no substitution - ("x-y", []), - # Empty string - ("", []), - # HYPHEN (U+2010) - ("x‐y", [("‐", "-")]), - # NON-BREAKING HYPHEN (U+2011) - ("x‑y", [("‑", "-")]), - # FIGURE DASH (U+2012) - ("x‒y", [("‒", "-")]), - # EN DASH (U+2013) - ("x–y", [("–", "-")]), - # EM DASH (U+2014) - ("x—y", [("—", "-")]), - # MINUS SIGN (U+2212) - ("x−y", [("−", "-")]), - # SMALL HYPHEN-MINUS (U+FE63) - ("x﹣y", [("﹣", "-")]), - # FULLWIDTH HYPHEN-MINUS (U+FF0D) - ("x-y", [("-", "-")]), - # Multiple different unicode dashes in one expression - ("x–y−z", [("–", "-"), ("−", "-")]), - # Repeated occurrences of the same dash — still one substitution pair - ("x−y−z", [("−", "-")]), - ] - ) - def test_convert_unicode_dashes(self, expr, expected): - result = convert_unicode_dashes(expr) - assert result == expected - - -class TestConvertAbsoluteNotation: - - @pytest.mark.parametrize( - "expr, expected_expr, has_feedback", - [ - # No pipes — unchanged - ("x+y", "x+y", False), - # Exactly two pipes — simple conversion - ("|x|", "Abs(x)", False), - ("|x+y|", "Abs(x+y)", False), - # Two non-adjacent absolute values - ("|x|+|y|", "Abs(x)+Abs(y)", False), - ] - ) - def test_convert_absolute_notation(self, expr, expected_expr, has_feedback): - result_expr, feedback = convert_absolute_notation(expr, "response") - assert result_expr == expected_expr - assert (feedback is not None) == has_feedback - - def test_ambiguous_pipes_produce_feedback(self): - # More than 2 pipes with ambiguous positions produces feedback - expr, feedback = convert_absolute_notation("|x|y|z|", "response") - assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - - -class TestTransformUnicodeGreekSymbols: - - def test_no_greek_symbols_returns_empty(self): - assert transform_unicode_greek_symbols("x + y") == [] - - def test_named_greek_symbol_returns_self_substitution(self): - result = transform_unicode_greek_symbols("alpha + 1") - assert ("alpha", " alpha ") in result - - def test_unicode_greek_alias_maps_to_name(self): - # α is an alias for "alpha" - result = transform_unicode_greek_symbols("α") - assert ("α", " alpha ") in result - - def test_multiple_greek_symbols(self): - result = transform_unicode_greek_symbols("alpha + beta") - assert ("alpha", " alpha ") in result - assert ("beta", " beta ") in result - - def test_unicode_beta_alias(self): - result = transform_unicode_greek_symbols("β") - assert ("β", " beta ") in result - - -class TestProtectElementaryFunctionsSubstitutions: - - def test_no_functions_returns_empty(self): - assert protect_elementary_functions_substitutions("x + y") == [] - - def test_sin_generates_self_substitution(self): - result = protect_elementary_functions_substitutions("sin(x)") - assert ("sin", " sin ") in result - - def test_alias_maps_to_canonical_name(self): - # arctan is an alias for atan - result = protect_elementary_functions_substitutions("arctan(x)") - assert ("arctan", " atan ") in result - - def test_multiple_functions(self): - result = protect_elementary_functions_substitutions("sin(x) + cos(x)") - assert ("sin", " sin ") in result - assert ("cos", " cos ") in result - - -class TestSubstituteInputSymbols: - - def test_plain_expression_unchanged(self): - result = substitute_input_symbols("x+y", {}) - assert result == ["x+y"] - - def test_lambda_replaced_with_lamda(self): - result = substitute_input_symbols("lambda", {}) - assert result == ["lamda"] - - def test_alias_replaced_with_symbol_code(self): - params = {"symbols": {"x": {"latex": r"\(x\)", "aliases": ["x_var"]}}} - result = substitute_input_symbols(["x_var"], params) - assert result == ["x"] - - def test_symbol_code_preserved(self): - params = {"symbols": {"x": {"latex": r"\(x\)", "aliases": ["x_var"]}}} - result = substitute_input_symbols(["x"], params) - assert result == ["x"] - - def test_list_input_accepted(self): - result = substitute_input_symbols(["x", "y"], {}) - assert result == ["x", "y"] - - -class TestFindMatchingParenthesis: - - @pytest.mark.parametrize( - "string, index, delimiters, expected", - [ - # Simple pair - ("(x)", 0, None, 2), - # Longer content - ("(x+y)", 0, None, 4), - # Nested — outer pair - ("((x+y))", 0, None, 6), - # Nested — inner pair - ("((x+y))", 1, None, 5), - # No closing delimiter - ("(x", 0, None, -1), - # Custom square-bracket delimiters - ("[x+y]", 0, ("[", "]"), 4), - # Starts mid-string - ("a(b+c)d", 1, None, 5), - ] - ) - def test_find_matching_parenthesis(self, string, index, delimiters, expected): - if delimiters is None: - result = find_matching_parenthesis(string, index) - else: - result = find_matching_parenthesis(string, index, delimiters) - assert result == expected - - -class TestHasMatchingBrackets: - - @pytest.mark.parametrize( - "expr, expected", [ - ("x+y", True), - ("(x+y)", True), - ("[x+y]", True), - ("{x+y}", True), - ("[x+(y-1)]", True), - ("{(x+1), (x-1)}", True), - # Mismatched bracket types - ("[x+y)", False), - ("(x+y]", False), - ("{x+y)", False), - ("[(x+y]", False), - # Unbalanced brackets - ("[x+y", False), - ("x+y]", False), - ("((x+y)", False), - ] - ) - def test_has_matching_brackets(self, expr, expected): - assert has_matching_brackets(expr) is expected - - -class TestConvertBracketNotation: - - @pytest.mark.parametrize( - "expr, expected", [ - ("[x+y]", "(x+y)"), - ("[x+(y-1)]", "(x+(y-1))"), - ("{x+1}*{x-2}", "(x+1)*(x-2)"), - ] - ) - def test_matched_brackets_are_converted(self, expr, expected): - result, feedback = convert_bracket_notation(expr) - assert result == expected - assert feedback is None - - def test_multiple_answers_wrapper_left_untouched(self): - result, feedback = convert_bracket_notation("{x+1, x-1}") - assert result == "{x+1, x-1}" - assert feedback is None - - @pytest.mark.parametrize( - "expr", [ - "[x+y)", - "(x+y]", - "{x+y)", - "[x+y", - "x+y]", - ] - ) - def test_mismatched_brackets_are_rejected(self, expr): - result, feedback = convert_bracket_notation(expr) - assert result == expr - assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" - - -class TestSubstitute: - - @pytest.mark.parametrize( - "string, substitutions, expected", - [ - # Empty substitutions — unchanged - ("hello", [], "hello"), - # Single substitution - ("hello world", [("world", "earth")], "hello earth"), - # Whole string replaced - ("abc", [("abc", "xyz")], "xyz"), - # No match — unchanged - ("hello", [("world", "earth")], "hello"), - # Earlier substitution in list wins at the same position - ("abc", [("a", "p"), ("ab", "q")], "pbc"), - # Longer substitution listed first wins - ("abc", [("ab", "q"), ("a", "p")], "qc"), - # Multiple non-overlapping substitutions - ("a b c", [("a", "x"), ("b", "y"), ("c", "z")], "x y z"), - # List input is joined into a single result - (["hello", " ", "world"], [("world", "earth")], "hello earth"), - ] - ) - def test_substitute(self, string, substitutions, expected): - assert substitute(string, substitutions) == expected - - def test_lookahead_tuple_matches_with_following_context(self): - # (("sin", ["("]), " sin ") matches "sin(" but not "sin " or "sinx" - subs = [(("sin", ["("]), " sin ")] - assert substitute("sin(x)", subs) == " sin (x)" - - def test_lookahead_tuple_does_not_match_without_context(self): - subs = [(("sin", ["("]), " sin ")] - assert substitute("sinx", subs) == "sinx" - - -class TestComputeRelativeTolerance: - - @pytest.mark.parametrize( - "string, expected", - [ - # Non-numeric → 0 - ("not_a_number", 0), - # 1 sig char, below DEFAULT_SIGNIFICANT_FIGURES=2 floor → 5e-2 - ("1", 5e-2), - # "0.5" → chars "05" → lstrip "5" → len 1 → floor applies → 5e-2 - ("0.5", 5e-2), - # "1.5" → chars "15" → len 2 → max(2,2)=2 → 5e-2 - ("1.5", 5e-2), - # "100" → chars "100" (lstrip removes nothing, '1' stops it) → len 3 → 5e-3 - ("100", 5e-3), - # "1.23" → chars "123" (decimal removed) → len 3 → 5e-3 - ("1.23", 5e-3), - # Scientific notation: mantissa "1.23" → len 3 → 5e-3 - ("1.23e5", 5e-3), - # Negative: "-1.23" → lstrip removes "-" → "123" → len 3 → 5e-3 - ("-1.23", 5e-3), - ] - ) - def test_relative_tolerance(self, string, expected): - result = compute_relative_tolerance_from_significant_decimals(string) - assert result == pytest.approx(expected) - - -class TestSympySymbols: - - def test_returns_symbol_objects(self): - result = sympy_symbols({"x": {}, "y": {}}) - assert result == {"x": Symbol("x"), "y": Symbol("y")} - - def test_empty_dict(self): - assert sympy_symbols({}) == {} - - def test_symbol_names_preserved(self): - result = sympy_symbols({"alpha": {}}) - assert result["alpha"] == Symbol("alpha") - - -class TestExtractLatex: - - @pytest.mark.parametrize( - "symbol, expected", - [ - # LaTeX delimiters removed - (r"\(x^2\)", "x^2"), - ("$x^2$", "x^2"), - ("$$x^2$$", "x^2"), - (r"\(\alpha\)", r"\alpha"), - # No delimiters — returned as-is - ("x^2", "x^2"), - ("plain", "plain"), - ] - ) - def test_extract_latex(self, symbol, expected): - assert extract_latex(symbol) == expected - - -class TestLatexSymbols: - - def test_maps_symbol_to_latex_string(self): - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = latex_symbols(syms) - assert result == {Symbol("x"): "x"} - - def test_greek_latex_preserved(self): - syms = {"alpha": {"latex": r"\(\alpha\)", "aliases": []}} - result = latex_symbols(syms) - assert result == {Symbol("alpha"): r"\alpha"} - - def test_empty_dict(self): - assert latex_symbols({}) == {} - - -class TestSympyToLatex: - - def test_simple_power(self): - expr = Symbol("x") ** 2 - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == "x^{2}" - - def test_custom_latex_name_used(self): - expr = Symbol("alpha") - syms = {"alpha": {"latex": r"\(\alpha\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == r"\alpha" - - def test_sqrt(self): - expr = sqrt(Symbol("x")) - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == r"\sqrt{x}" - - -class TestSubstitutionsSortKey: - - def test_longer_left_element_sorts_first(self): - long_sub = ("abc", "p") - short_sub = ("ab", "p") - assert substitutions_sort_key(long_sub) < substitutions_sort_key(short_sub) - - def test_equal_left_length_longer_right_sorts_first(self): - long_right = ("ab", "pqr") - short_right = ("ab", "p") - assert substitutions_sort_key(long_right) < substitutions_sort_key(short_right) - - def test_sort_orders_longer_substitutions_first(self): - subs = [("a", "x"), ("abc", "y"), ("ab", "z")] - subs.sort(key=substitutions_sort_key) - assert subs[0][0] == "abc" - assert subs[1][0] == "ab" - assert subs[2][0] == "a" - - -class TestCreateExpressionSet: - - def test_plain_string_wrapped_in_list(self): - result = create_expression_set("x+y", {}) - assert result == ["x+y"] - - def test_set_notation_split_into_list(self): - result = create_expression_set("{x, y}", {}) - assert sorted(result) == ["x", "y"] - - def test_list_input_accepted(self): - result = create_expression_set(["x", "y"], {}) - assert sorted(result) == ["x", "y"] - - def test_plus_minus_expands_to_two_expressions(self): - params = {"plus_minus": "±"} - result = create_expression_set("±x", params) - assert sorted(result) == sorted(["+x", "-x"]) or sorted(result) == sorted(["x", "-x"]) - assert len(result) == 2 - - def test_curly_braces_used_for_grouping_are_not_split(self): - result = create_expression_set("{x+1}*{x-2}", {}) - assert result == ["{x+1}*{x-2}"] - - -class TestIsMultipleAnswersWrapper: - - @pytest.mark.parametrize( - "expr, expected", [ - ("{x, y}", True), - ("{x+1}", True), - ("{(x+1), (x-1)}", True), - ("x+y", False), - ("{x+1}*{x-2}", False), - ("{x+1}*x", False), - ] - ) - def test_is_multiple_answers_wrapper(self, expr, expected): - assert is_multiple_answers_wrapper(expr) is expected - - -class TestPreprocessExpression: - - def test_plain_expression_succeeds(self): - success, expr, feedback = preprocess_expression("response", "x+y", {}) - assert success is True - assert expr == "x+y" - assert feedback is None - - def test_absolute_value_notation_converted(self): - success, expr, feedback = preprocess_expression("response", "|x|", {}) - assert success is True - assert expr == "Abs(x)" - assert feedback is None - - def test_ambiguous_pipes_returns_failure(self): - success, expr, feedback = preprocess_expression("response", "|x|y|z|", {}) - assert success is False - assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - - def test_square_brackets_converted(self): - success, expr, feedback = preprocess_expression("response", "[x+y]", {}) - assert success is True - assert expr == "(x+y)" - assert feedback is None - - @pytest.mark.parametrize( - "expr", [ - "[x+y)", - "(x+y]", - "{x+y)", - "[x+y", - "x+y]", - ] - ) - def test_mismatched_brackets_returns_failure(self, expr): - success, result, feedback = preprocess_expression("response", expr, {}) - assert success is False - assert result == expr - assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" \ No newline at end of file diff --git a/app/tests/multi_character_test.py b/app/tests/multi_character_test.py deleted file mode 100644 index ed072e9..0000000 --- a/app/tests/multi_character_test.py +++ /dev/null @@ -1,223 +0,0 @@ -import pytest -from ..utility.expression_utilities import parse_expression, create_sympy_parsing_params - -class TestMultiCharImplicitMultiHigherPrecedenceIntegration: - """ - Integration tests for multi-characters symbols and the convention of implicit_higher_precedence enabled. - """ - - def assert_expression_equality(self, response, answer, symbols_dict, local_dict=None): - """ - Helper to parse both response and answer with the same parameters - and assert they result in equivalent SymPy expressions. - """ - if local_dict is None: - local_dict = {} - - # define parameters - params = { - "complexNumbers": False, - "strict_syntax": False, - "elementary_functions": True, - "convention": "implicit_higher_precedence", - "symbols": symbols_dict, - } - - parsing_params = create_sympy_parsing_params(params) - - try: - parsed_response = parse_expression(response, parsing_params) - parsed_answer = parse_expression(answer, parsing_params) - except Exception as e: - pytest.fail(f"Parsing failed for input '{response}' or '{answer}': {str(e)}") - - assert parsed_response == parsed_answer, \ - f"\nInput: {response}\nExpected: {parsed_answer}\nGot: {parsed_response}" - - def test_multi_character_implicit_multi_variable(self): - symbols = { - "a": {"aliases": ["a"]}, - "bc": {"aliases": ["bc"]}, - "d": {"aliases": ["d"]} - } - answer = "a/(bc*d)" - - # Case 1: Full explicit - self.assert_expression_equality("a/(bc*d)", answer, symbols) - - # Case 2: Implicit with brackets - self.assert_expression_equality("a/(bcd)", answer, symbols) - - # Case 3: Implicit no brackets - self.assert_expression_equality("a/bcd", answer, symbols) - - def test_multiple_divisions_with_implicit(self): - """Test multiple divisions in sequence""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]} - } - - answer = "a/(b*c)/(d*f)" - response = "a/bc/df" - self.assert_expression_equality(response, answer, symbols) - - def test_addition_with_division_and_implicit(self): - """Test that addition doesn't interfere""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]} - } - answer = "a/(b*c) + d" - response = "a/bc + d" - self.assert_expression_equality(response, answer, symbols) - - def test_multiplication_after_division(self): - """Test explicit multiplication after division""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]} - } - - answer = "(a/(b*c))*d" - response = "a/bc*d" - self.assert_expression_equality(response, answer, symbols) - - def test_power_with_implicit_multiplication(self): - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - } - answer = "a/(b**2*c)" - response = "a/b^2c" - self.assert_expression_equality(response, answer, symbols) - - def test_longer_multi_character_symbols(self): - symbols = { - "x": {"aliases": ["x"]}, - "abc": {"aliases": ["abc"]}, - "df": {"aliases": ["df"]}, - } - answer = "x/(abc*df)" - test_cases = [ - "x/(abc*df)", - "x/(abcdf)", - "x/abcdf", - ] - for response in test_cases: - self.assert_expression_equality(response, answer, symbols) - - def test_overlapping_symbol_names(self): - symbols = { - "a": {"aliases": ["a"]}, - "ab": {"aliases": ["ab"]}, - "abc": {"aliases": ["abc"]}, - "c": {"aliases": ["c"]}, - } - answer = "a/(abc*c)" - response = "a/abcc" - self.assert_expression_equality(response, answer, symbols) - - def test_numbers_with_implicit_multiplication(self): - """Test with numeric literals""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - } - - answer = "a/(2*b)" - response = "a/2b" - self.assert_expression_equality(response, answer, symbols) - - def test_number_variable_number(self): - """Test number followed by variable followed by number""" - symbols = { - "x": {"aliases": ["x"]}, - } - - answer = "1/(2*x*3)" - response = "1/2x3" - self.assert_expression_equality(response, answer, symbols) - - def test_implicit_before_parentheses(self): - """Test implicit multiplication before parentheses""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - } - - answer = "a*(b*c*d)" - response = "a(bcd)" - self.assert_expression_equality(response, answer, symbols) - - def test_implicit_before_parentheses_with_multichar(self): - """Test implicit multiplication before parentheses with multi-char symbols""" - symbols = { - "a": {"aliases": ["a"]}, - "bc": {"aliases": ["bc"]}, - "d": {"aliases": ["d"]}, - } - - answer = "a*(bc*d)" - response = "a(bcd)" - self.assert_expression_equality(response, answer, symbols) - - def test_complex_expression(self): - """Test a complex expression""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]}, - "g": {"aliases": ["g"]}, - } - answer = "a/(b*c) + d/(g*f)" - response = "a/bc + d/gf" - self.assert_expression_equality(response, answer, symbols) - - def test_three_way_implicit_multiplication(self): - """Test three or more implicitly multiplied terms""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]}, - } - answer = "a/(b*c*d*f)" - response = "a/bcdf" - self.assert_expression_equality(response, answer, symbols) - - def test_explicit_multiplication_should_not_be_grouped(self): - """Test that explicit multiplication maintains standard precedence""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - } - answer = "(a/b)*c" - response = "a/b*c" - self.assert_expression_equality(response, answer, symbols) - - def test_mixed_implicit_and_explicit(self): - """Test mixing implicit and explicit multiplication""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - } - answer = "(a/(b*c))*d" - response = "a/bc*d" - self.assert_expression_equality(response, answer, symbols) \ No newline at end of file diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index 6d1f635..276517a 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -1,15 +1,15 @@ import pytest -import os # Import necessary data and reference cases for tests -from .slr_quantity_test import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases +from .physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases from ..evaluation import evaluation_function -from ..utility.unit_system_conversions import ( - set_of_SI_prefixes, - set_of_SI_base_unit_dimensions, - set_of_derived_SI_units_in_SI_base_units, - set_of_common_units_in_SI, set_of_very_common_units_in_SI, - set_of_imperial_units +from lambdafeedback.units import ( + COMMON_UNITS, + IMPERIAL_UNITS, + SI_BASE_UNITS, + SI_DERIVED_UNITS, + SI_PREFIXES, + VERY_COMMON_UNITS, ) @@ -31,19 +31,8 @@ class TestEvaluationFunction(): Use evaluation_function() to call the evaluation function. """ - # Import tests that makes sure that physical quantity parsing works as expected - from .slr_quantity_test import TestEvaluationFunction as TestStrictSLRSyntax - - log_details = True - - def log_details_to_file(self, details, filename): - if self.log_details: - f = open(filename, "w") - f.write(details) - f.close() - return - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) + @pytest.mark.slow def test_strict_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, @@ -59,6 +48,7 @@ def test_strict_syntax_cases(self, string, value, unit, content, value_latex, un assert result["response_latex"] == "~".join([latex for latex in [value_latex, unit_latex] if latex is not None]) @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) + @pytest.mark.slow def test_natural_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, @@ -76,12 +66,11 @@ def test_natural_syntax_cases(self, string, value, unit, content, value_latex, u @pytest.mark.skip("Too resource intensive") def test_slow_quantity_alternative_names_natural_syntax(self): params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI common imperial", "strictness": "natural"} - units = set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units + units = SI_BASE_UNITS + SI_DERIVED_UNITS + COMMON_UNITS + VERY_COMMON_UNITS + IMPERIAL_UNITS incorrect = [] errors = [] n = 0 - k = 0 - for prefix in set_of_SI_prefixes: + for prefix in SI_PREFIXES: for u1 in units: for u2 in units: if u1 is not u2: @@ -97,25 +86,19 @@ def test_slow_quantity_alternative_names_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - print("Incorrect: "+str((answer, response, result["response_latex"]))) - if n-k > 0: - print(k) - k += 1000 m = len(errors)+len(incorrect) details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) - self.log_details_to_file(details, "test_quantity_alternative_names_natural_syntax_log.txt") # Current number of collisions caused by concatenating two units, e.g. "barnewton" has "barn" as substring, "aremole" has "rem" as substring etc. - assert len(errors)+len(incorrect) <= 144 + assert m <= 144, details @pytest.mark.skip("Too resource intensive") def test_slow_quantity_short_forms_natural_syntax(self): params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI common imperial", "strictness": "natural"} - units = set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units + units = SI_BASE_UNITS + SI_DERIVED_UNITS + COMMON_UNITS + VERY_COMMON_UNITS + IMPERIAL_UNITS incorrect = [] errors = [] n = 0 - k = 0 - for prefix in set_of_SI_prefixes: + for prefix in SI_PREFIXES: for u1 in units: for u2 in units: if u1 is not u2 and (prefix[1]+u1[1] not in [u[1] for u in units]): @@ -130,14 +113,10 @@ def test_slow_quantity_short_forms_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - if n-k > 0: - print(k) - k += 1000 m = len(errors)+len(incorrect) details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) - self.log_details_to_file(details, "test_quantity_short_forms_natural_syntax_units_log.txt") # Current number of collisions caused by concatenating short forms for units, e.g. 'femtometre inch' and 'femtominute' both have short form 'fmin' - assert len(errors)+len(incorrect) <= 551 + assert m <= 551, details @pytest.mark.parametrize( "value,unit,small_diff,large_diff", @@ -150,6 +129,7 @@ def test_slow_quantity_short_forms_natural_syntax(self): ("100*10**10", "kg m/s^2", 4*10**9, 6*10**9) ] ) + @pytest.mark.slow def test_compute_relative_tolerance_from_significant_digits(self, value, unit, small_diff, large_diff): ans = value+" "+unit res_correct_under = str(eval(value)-small_diff)+" "+unit @@ -319,6 +299,7 @@ def test_print_floating_point_approximation_of_very_large_numbers(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is False + @pytest.mark.filterwarnings("ignore:legacy_preprocessing is deprecated:DeprecationWarning") def test_legacy_strictness(self): ans = "100*kilo*pascal*ohm" res = "100 kilopascal ohm" @@ -327,7 +308,8 @@ def test_legacy_strictness(self): 'physical_quantity': True, 'strictness': 'legacy', } - result = evaluation_function(res, ans, params, include_test_data=True) + with pytest.warns(DeprecationWarning, match="legacy_preprocessing is deprecated"): + result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is True ans = "8650*watt" res = "8.65kW" @@ -425,7 +407,6 @@ def test_answer_zero_value(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is False - def test_physical_quantity_with_atol_and_kg_unit(self): ans = "20 kg" params = { @@ -576,6 +557,7 @@ def test_physical_quantity_with_rtol_and_imperial_mass_unit(self): ("30 degree", "30 °"), ] ) + @pytest.mark.slow def test_greek_letter_units(self, ans, res): params = { 'strict_syntax': False, @@ -584,7 +566,3 @@ def test_greek_letter_units(self, ans, res): } result = evaluation_function(res, ans, params) assert result["is_correct"] is True - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--no-header", os.path.abspath(__file__)]) diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index 147cf69..392ce81 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -1,11 +1,12 @@ -import os +from copy import deepcopy + import pytest -from ..preview_implementations.physical_quantity_preview import preview_function -from .slr_quantity_test import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases +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 -class TestPreviewFunction(): +class TestPreviewFunction: """ TestCase Class used to test the algorithm. --- @@ -24,14 +25,17 @@ class TestPreviewFunction(): as it should. """ - @pytest.mark.parametrize("response,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) + @pytest.mark.parametrize( + "response,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases + ) + @pytest.mark.slow def test_strict_syntax_cases(self, response, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict", - "elementary_functions": True + "elementary_functions": True, } result = preview_function(response, params)["preview"] latex = "" @@ -40,17 +44,19 @@ def test_strict_syntax_cases(self, response, value, unit, content, value_latex, elif value_latex is not None and unit_latex is None: latex = value_latex elif value_latex is not None and unit_latex is not None: - latex = value_latex+"~"+unit_latex + latex = value_latex + "~" + unit_latex assert result["latex"] == latex - @pytest.mark.parametrize("response,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) + @pytest.mark.parametrize( + "response,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases + ) def test_natural_syntax_cases(self, response, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "natural", - "elementary_functions": True + "elementary_functions": True, } result = preview_function(response, params)["preview"] latex = "" @@ -59,7 +65,7 @@ def test_natural_syntax_cases(self, response, value, unit, content, value_latex, elif value_latex is not None and unit_latex is None: latex = value_latex elif value_latex is not None and unit_latex is not None: - latex = value_latex+"~"+unit_latex + latex = value_latex + "~" + unit_latex assert result["latex"] == latex @pytest.mark.parametrize( @@ -67,7 +73,7 @@ def test_natural_syntax_cases(self, response, value, unit, content, value_latex, [ ("sin(123)", r"\sin{\left(123 \right)}", "sin(123)"), ("sqrt(162)", r"\sqrt{162}", "sqrt(162)"), - ] + ], ) def test_issue_with_function_name_that_can_be_compound_unit(self, response, preview_latex, preview_sympy): params = { @@ -86,15 +92,16 @@ def test_handwritten_input(self): } response = "162 \\mathrm{~N} / \\mathrm{m}^{2}" result = preview_function(response, params)["preview"] - assert result["latex"] == r'162~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}' # TODO: Fix so that unnecessary parenthesis are simplified away + assert result["latex"] == r"162~\frac{\mathrm{newton}}{\mathrm{metre}^{2}}" assert result["sympy"] == "162 newton/metre**(2)" @pytest.mark.parametrize( - "response, latex, sympy", [ + "response, latex, sympy", + [ (r"\frac{F}{p \cdot \mu}", r"\frac{F}{\mu p}", "F/((mu*p))"), (r"F \cdot p", "F p", "F*p"), (r"\frac{10}{2} \mathrm{~kg}", r"\frac{10}{2}~\mathrm{kilogram}", "10/2 kilogram"), - ] + ], ) def test_latex_physical_quantity_preview(self, response, latex, sympy): params = { @@ -106,6 +113,33 @@ def test_latex_physical_quantity_preview(self, response, latex, sympy): assert result["latex"] == latex assert result["sympy"] == sympy + @pytest.mark.parametrize( + ("latex", "expected"), + [ + ("m^{-2}", "m**(-2)"), + ("x^{23}", "x**(23)"), + # With '**' the notation used to be skipped twice, eating the exponent's first character. + ("m**{-2}", "m**(-2)"), + ("x**{23}", "x**(23)"), + ("x^2", "x**2"), + # An unbraced exponent used to borrow the next brace anywhere in the string. + ("x^2y^{3}", "x**2y**(3)"), + ], + ) + def test_fix_exponents(self, latex, expected): + assert fix_exponents(latex) == expected + + def test_latex_preview_keeps_the_exponent_sign(self): + preview = preview_function(r"162 \mathrm{~N} \mathrm{~m}**{-2}", {"is_latex": True})["preview"] + assert preview["sympy"] == "162 newton metre**(-2)" + + def test_latex_preview_of_a_unit_alone(self): + assert preview_function(r"\mathrm{kg}", {"is_latex": True})["preview"]["sympy"] == "kilogram" -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) + def test_preview_does_not_mutate_params(self): + # Fixed while porting units to the typed expression_parsing API + # (the LaTeX branch set params["is_latex"] = False on the caller's dict). + params = {"is_latex": True, "symbols": {"F": {"latex": "F", "aliases": []}}} + before = deepcopy(params) + preview_function(r"3 \mathrm{kg}", params) + assert params == before diff --git a/app/tests/slr_quantity_test.py b/app/tests/physical_quantity_test_cases.py similarity index 65% rename from app/tests/slr_quantity_test.py rename to app/tests/physical_quantity_test_cases.py index 5f0ac5a..2a75336 100644 --- a/app/tests/slr_quantity_test.py +++ b/app/tests/physical_quantity_test_cases.py @@ -1,10 +1,6 @@ -import pytest -import os - -from ..context.physical_quantity import SLR_quantity_parser, SLR_quantity_parsing, default_parameters -from ..utility.unit_system_conversions import\ - set_of_SI_base_unit_dimensions, set_of_derived_SI_units_in_SI_base_units,\ - set_of_common_units_in_SI, set_of_very_common_units_in_SI, set_of_imperial_units +# Physical quantities with their expected value, unit and LaTeX forms, shared by +# the physical quantity evaluation tests. The quantity parser itself is tested in +# the lambdafeedback-units package. slr_strict_si_syntax_test_cases = [ ("q", # String that will be used as response / answer @@ -54,21 +50,21 @@ "kilogram/(metre second^2)", "kilogram/(metre second^2)", None, - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["NO_VALUE"]), ("metre^(3/2)", None, "metre^(3/2)", "metre^(3/2)", None, - r"\mathrm{metre}^{(\frac{3}{2})}", + r"\mathrm{metre}^{\frac{3}{2}}", ["NO_VALUE"]), ("10 kilogram/(metre second^2)", "10", "kilogram/(metre second^2)", "10 kilogram/(metre second^2)", "10", - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10 kilogram*metre/second**2", "10", @@ -89,7 +85,7 @@ "kilogram*metre*second**(-2)", "10 kilogram*metre*second**(-2)", "10", - r"\mathrm{kilogram}\cdot\mathrm{metre}\cdot\mathrm{second}^{(-2)}", + r"\mathrm{kilogram}\cdot\mathrm{metre}\cdot\mathrm{second}^{-2}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10*pi kilogram*metre/second^2", "10*pi", @@ -109,26 +105,26 @@ "(kilogram megametre^2)/(fs^4 daA)", "(kilogram megametre^2)/(femtosecond^4 decaampere)", None, - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["NO_VALUE"]), ("(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3)", "(kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{4.3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(5.27*pi/sqrt(11) + 5*7)^(2+2.3) (kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(2+2.3)", "(kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(2+2.3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{2 + 2.3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(5*27/11 + 5*7)^(2*3) (kilogram megametre^2)/(fs^4 daA)", "(5*27/11 + 5*7)^(2*3)", "(kilogram megametre^2)/(fs^4 daA)", "(5*27/11 + 5*7)^(2*3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(5 \cdot 27 \cdot \frac{1}{11} + 5 \cdot 7\right)^{2 \cdot 3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(pi+10) kg*m/s^2", "(pi+10)", @@ -298,14 +294,14 @@ "kilogram/(metresecond^2)", "kilogram/(metre second^2)", None, - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["NO_VALUE"]), ("10 kilogram/(metresecond^2)", "10", "kilogram/(metresecond^2)", "10 kilogram/(metre second^2)", "10", - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10 kilogrammetre/second**2", "10", @@ -340,7 +336,7 @@ "kilogrammetresecond**(-2)", "10 kilogram metre second**(-2)", "10", - r"\mathrm{kilogram}~\mathrm{metre}~\mathrm{second}^{(-2)}", + r"\mathrm{kilogram}~\mathrm{metre}~\mathrm{second}^{-2}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10*pi kilogrammetre/second^2", "10*pi", @@ -447,130 +443,3 @@ r"\frac{1}{\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ] - - -class TestEvaluationFunction(): - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) - def test_strict_si_syntax(self, string, value, unit, content, value_latex, unit_latex, criteria): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI", - "strictness": "strict", - "elementary_functions": True, - } - ) - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(string, parameters, parser, "quantity") - parsed_value = quantity.value.original_string() if quantity.value is not None else None - parsed_unit = quantity.unit.original_string() if quantity.unit is not None else None - parsed_value_latex = quantity.value_latex_string - parsed_unit_latex = quantity.unit_latex_string - parsed_content = quantity.ast_root.content_string() - assert parsed_value == value - assert parsed_unit == unit - assert parsed_content == content - assert parsed_value_latex == value_latex - assert parsed_unit_latex == unit_latex - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units] - ) - def test_short_forms_strict_SI(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI] - ) - def test_short_forms_common_SI(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "common", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_imperial_units] - ) - def test_short_forms_imperial(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "imperial", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units] - ) - def test_short_forms_all(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI common imperial", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) - def test_natural_si_syntax(self, string, value, unit, content, value_latex, unit_latex, criteria): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI common imperial", - "strictness": "natural", - "elementary_functions": True - } - ) - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(string, parameters, parser, "quantity") - parsed_unit_latex = quantity.unit_latex_string - parsed_value = quantity.value.original_string() if quantity.value is not None else None - parsed_unit = quantity.unit.original_string() if quantity.unit is not None else None - parsed_value_latex = quantity.value_latex_string - parsed_unit_latex = quantity.unit_latex_string - parsed_content = quantity.ast_root.content_string() - assert parsed_value == value - assert parsed_unit == unit - assert parsed_content == content - assert parsed_value_latex == value_latex - assert parsed_unit_latex == unit_latex - - -if __name__ == "__main__": - pytest.main(["-xs", "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 89243cb..9752e91 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -1,8 +1,8 @@ import pytest -import os from ..evaluation import evaluation_function -from ..utility.expression_utilities import elementary_functions_names, substitute +from lambdafeedback.expression_parsing import substitute +from lambdafeedback.expression_parsing.symbol_tables import ELEMENTARY_FUNCTIONS as elementary_functions_names # REMARK: If a case is marked with an alternative output, this means that it is difficult in this case to prevent sympy from simplifying for that particular case elementary_function_test_cases = [ @@ -211,6 +211,7 @@ def test_complicated_expression_correct(self, response, answer): input_variations += generate_input_variations(response, answer) @pytest.mark.parametrize("response,answer", input_variations) + @pytest.mark.slow def test_simple_fractional_powers_correct(self, response, answer): params = {"strict_syntax": False, "symbol_assumptions": "('g','positive') ('v','positive')"} result = evaluation_function(response, answer, params) @@ -452,6 +453,7 @@ def test_special_functions(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_correct(self, response, answer): params = { "strict_syntax": False, @@ -467,6 +469,7 @@ def test_plus_minus_all_correct(self, response, answer): answer="+- x**2 + -+ y**2" ) ) + @pytest.mark.slow def test_plus_minus_replace_symbols_all_correct(self, response, answer): params = {"plus_minus": "+-", "minus_plus": "-+", "strict_syntax": False} result = evaluation_function(response, answer, params) @@ -479,6 +482,7 @@ def test_plus_minus_replace_symbols_all_correct(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_incorrect(self, response, answer): params = {"strict_syntax": False} result = evaluation_function(response, answer, params) @@ -503,6 +507,7 @@ def test_plus_minus_all_responses_correct(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_responses_incorrect(self, response, answer): params = {"multiple_answers_criteria": "all_responses", "strict_syntax": False} result = evaluation_function(response, answer, params) @@ -1004,6 +1009,7 @@ def test_pi_with_rel_tol(self): ] ] ) + @pytest.mark.slow def test_PHYS40002_2_2_b(self, response, outcome): params = {"strict_syntax": False} answer = "-(y*sin(x*y) + e^(y)) / (x*(e^(y) + sin(x*y)))" @@ -1031,6 +1037,7 @@ def test_PHYS40002_2_2_b(self, response, outcome): ] ] ) + @pytest.mark.slow def test_PHYS40002_2_6_a(self, response, outcome): params = {"strict_syntax": False} answer = "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)" @@ -1054,6 +1061,7 @@ def test_PHYS40002_2_6_a(self, response, outcome): [] ) ) + @pytest.mark.slow def test_elementary_functions(self, description, response, answer): params = {"strict_syntax": False, "elementary_functions": True} result = evaluation_function(response, answer, params) @@ -1272,6 +1280,7 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): ("2*pi", "0", "tan(response) = 0", True, [], {}), ] ) + @pytest.mark.slow def test_criteria_based_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): params = { "strict_syntax": False, @@ -1892,6 +1901,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac ), ] ) + @pytest.mark.slow def test_criteria_where_numerical_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): params = { "strict_syntax": False, @@ -2042,6 +2052,7 @@ def test_alternatives_to_input_symbols_takes_priority_over_elementary_function_a result = evaluation_function(response, answer, params) assert result["is_correct"] is False + @pytest.mark.slow def test_elementary_function_symbol_with_implicit_multiplication_on_both_sides(self): response = "momega^3 l^2/2(exp(2omegat)-exp(-2omegat))" answer = "m*omega^3*l^2*sinh(2*omega*t)" @@ -2053,7 +2064,7 @@ def test_elementary_function_symbol_with_implicit_multiplication_on_both_sides(s assert result["is_correct"] is True def test_curly_braces_used_for_grouping_in_response(self): - response = "2 pi e^{-a |omega|}" # {} is accepted as another way of writing () for grouping + response = "2 pi e^{-a |omega|}" # {} is accepted as another way of writing () for grouping answer = "2 pi e^(-a|omega|)" params = { 'atol': 0, @@ -2066,8 +2077,8 @@ def test_curly_braces_used_for_grouping_in_response(self): assert result["is_correct"] is True def test_unexpected_equalities_in_response_that_generates_set(self): - response = "z= plus_minus 1 + 2*i" # plus_minus generates a set of two equalities - answer = "2i plus_minus 1" # plus_minus generates a set of two expressions + response = "z= plus_minus 1 + 2*i" # plus_minus generates a set of two equalities + answer = "2i plus_minus 1" # plus_minus generates a set of two expressions params = { 'atol': 0, 'rtol': 0, @@ -2144,7 +2155,7 @@ def test_equality_with_both_sides_equal_in_response(self): } result = evaluation_function(response, answer, params) assert result["is_correct"] is False - + def test_integral(self): response = "6y" answer = "int(3*x*y, (x, 0, 2))" @@ -2247,7 +2258,3 @@ def test_greek_letters_in_expressions(self, unicode_expr, letter_expr): } result = evaluation_function(response, answer, params) assert result["is_correct"] is True - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", '--durations=10', os.path.abspath(__file__)]) diff --git a/app/tests/symbolic_preview_test.py b/app/tests/symbolic_preview_test.py deleted file mode 100644 index cb86d62..0000000 --- a/app/tests/symbolic_preview_test.py +++ /dev/null @@ -1,258 +0,0 @@ -import os -import pytest - -from ..utility.preview_utilities import Params, extract_latex -from ..preview_implementations.symbolic_preview import preview_function -from .symbolic_evaluation_test import elementary_function_test_cases - - -class TestPreviewFunction(): - """ - TestCase Class used to test the algorithm. - --- - Tests are used here to check that the algorithm written - is working as it should. - - It's best practise to write these tests first to get a - kind of 'specification' for how your algorithm should - work, and you should run these tests before committing - your code to AWS. - - Read the docs on how to use unittest here: - https://docs.python.org/3/library/unittest.html - - Use preview_function() to check your algorithm works - as it should. - """ - - def test_doesnt_simplify_latex_by_default(self): - response = "\\frac{x + x^2 + x}{x}" - params = Params(is_latex=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("sympy") == "(x**2 + x + x)/x" - - def test_doesnt_simplify_sympy_by_default(self): - response = "(x + x**2 + x)/x" - params = Params(is_latex=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\frac{x^{2} + x + x}{x}" - - def test_simplifies_latex_on_param(self): - response = "\\frac{x + x^2 + x}{x}" - params = Params(is_latex=True, simplify=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("sympy") == "x + 2" - - def test_simplifies_sympy_on_param(self): - response = "(x + x**2 + x)/x" - params = Params(is_latex=False, simplify=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("latex") == "x + 2" - - def test_sympy_handles_implicit_multiplication(self): - response = "sin(x) + cos(2x) - 3x**2" - params = Params(is_latex=False, strict_syntax=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == r"- 3 \cdot x^{2} + \sin{\left(x \right)} + \cos{\left(2 \cdot x \right)}" - - def test_latex_with_equality_symbol(self): - response = "\\frac{x + x^2 + x}{x} = y" - params = Params(is_latex=True, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") == '(x**2 + x + x)/x=y' - - def test_sympy_with_equality_symbol(self): - response = "Eq((x + x**2 + x)/x, 1)" - params = Params(is_latex=False, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\frac{x^{2} + x + x}{x} = 1" - - def test_latex_with_plus_minus(self): - response = r"\pm \frac{3}{\sqrt{5}} i" - params = Params( - is_latex=True, - simplify=False, - complexNumbers=True, - symbols={ - "I": { - "latex": "$i$", - "aliases": ["i"], - }, - "plus_minus": { - "latex": "$\\pm$", - "aliases": ["pm", "+-"], - }, - } - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in {'{3*(sqrt(5)/5)*I, -3*sqrt(5)/5*I}', '{-3*sqrt(5)/5*I, 3*(sqrt(5)/5)*I}'} - assert preview.get("latex") == r'\pm \frac{3}{\sqrt{5}} i' - response = r"4 \pm \sqrt{6}}" - params = Params( - is_latex=True, - simplify=False, - complexNumbers=True, - symbols={ - "plus_minus": { - "latex": "$\\pm$", - "aliases": ["pm", "+-"], - }, - } - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in {'{sqrt(6) + 4, 4 - sqrt(6)}', '{4 - sqrt(6), sqrt(6) + 4}'} - assert preview.get("latex") == r'4 \pm \sqrt{6}}' - - def test_latex_conversion_preserves_default_symbols(self): - response = "\\mu + x + 1" - params = Params(is_latex=True, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in "mu + x + 1" - - def test_sympy_conversion_preserves_default_symbols(self): - response = "mu + x + 1" - params = Params(is_latex=False, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\mu + x + 1" - - def test_latex_conversion_preserves_optional_symbols(self): - response = "m_{ \\text{table} } + \\text{hello}_\\text{world} - x + 1" - params = Params( - is_latex=True, - simplify=False, - symbols={ - "m_table": { - "latex": r"hello \( m_{\text{table}} \) world", - "aliases": [], - }, - "test": { - "latex": r"hello $ \text{hello}_\text{world} $ world.", - "aliases": [], - }, - }, - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") == "m_table + test - x + 1" - - def test_sympy_conversion_preserves_optional_symbols(self): - response = "m_table + test + x + 1" - params = Params( - is_latex=False, - simplify=False, - symbols={ - "m_table": {"latex": "m_{\\text{table}}", "aliases": []}, - "test": { - "latex": "\\text{hello}_\\text{world}", - "aliases": [], - }, - }, - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "m_{\\text{table}} + \\text{hello}_\\text{world} + x + 1" - - def test_invalid_latex_returns_error(self): - response = "\frac{ m_{ \\text{table} } + x + 1 }{x" - params = Params( - is_latex=True, - simplify=False, - symbols={"m_table": {"latex": "m_{\\text{table}}", "aliases": []}}, - ) - - with pytest.raises(ValueError): - preview_function(response, params) - - def test_invalid_sympy_returns_error(self): - response = "x + x***2 - 3 / x 4" - params = Params(simplify=False, is_latex=False) - - with pytest.raises(ValueError): - preview_function(response, params) - - def test_extract_latex_in_delimiters(self): - parentheses = r"\( x + 1 \)" - dollars = r"$ x ** 2 + 1 $" - double_dollars = r"$$ \sin x + \tan x $$" - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - assert extract_latex(double_dollars) == r" \sin x + \tan x " - - def test_extract_latex_in_delimiters_and_text(self): - parentheses = r"hello \( x + 1 \) world." - dollars = r"hello $ x ** 2 + 1 $ world." - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - - def test_extract_latex_no_delimiters(self): - test = r"'\sin x + \left ( \text{hello world} \right ) + \cos x" - assert extract_latex(test) == test - - def test_extract_latex_multiple_expressions(self): - parentheses = r"hello \( x + 1 \) world. \( \sin x + \cos x \) yes." - dollars = r"hello $ x ** 2 + 1 $ world. \( \sigma \times \alpha \) no." - mixture = r"hello $ x ** 2 - 1 $ world. $ \sigma \times \alpha $ !." - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - assert extract_latex(mixture) == " x ** 2 - 1 " - - def test_elementary_functions_preview(self): - params = {"strict_syntax": False, "elementary_functions": True} - for case in elementary_function_test_cases: - response = case[1] - result = preview_function(response, params) - assert result["preview"]["latex"] == case[3] - - def test_implicit_multiplication_convention_implicit_higher_precedence(self): - response = "1/ab" - latex = r"1 \cdot \frac{1}{a \cdot b}" # REMARK: Here it would be preferable to not have the '1 \cdot ' at the start - params = {"strict_syntax": False, "convention": "implicit_higher_precedence"} - result = preview_function(response, params) - assert result["preview"]["latex"] == latex - - def test_implicit_multiplication_convention_equal_precedence(self): - latex = r"1 \cdot \frac{1}{a} \cdot b" # REMARK: Here it would be preferable to not have the '1 \cdot ' at the start - params = {"strict_syntax": False, "convention": "equal_precedence"} - response_a = "1/ab" - result_a = preview_function(response_a, params) - assert result_a["preview"]["latex"] == latex - response_b = "1/a*b" - result_b = preview_function(response_b, params) - assert result_b["preview"]["latex"] == latex - - def test_MECH50001_2_24_a(self): - params = { - "strict_syntax": False, - "elementary_functions": True, - 'symbols': { - 'alpha': {'aliases': [], 'latex': r'\alpha'}, - 'Derivative(q,t)': {'aliases': ['q_{dot}', 'q_dot'], 'latex': r'\dot{q}'}, - 'Derivative(T,t)': {'aliases': ['dT/dt'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}t}'}, - 'Derivative(T,x)': {'aliases': ['dT/dx'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}x}'}, - 'Derivative(T,x,x)': {'aliases': ['(d^2 T)/(dx^2)', 'd^2 T/dx^2', 'd^2T/dx^2'], 'latex': r'\frac{\mathrm{d}^2 T}{\mathrm{d}x^2}'}, - }, - } - response = "d^2T/dx^2 + q_dot/k = 1/alpha*(dT/dt)" - result = preview_function(response, params) - assert result["preview"]["latex"] == r'\frac{d^{2}}{d x^{2}} T + \frac{\frac{d}{d t} q}{k}=1 \cdot \frac{1}{\alpha} \cdot \frac{d}{d t} T' - - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/syntactical_comparison_utilities_test.py b/app/tests/syntactical_comparison_utilities_test.py deleted file mode 100644 index df62243..0000000 --- a/app/tests/syntactical_comparison_utilities_test.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from ..utility.syntactical_comparison_utilities import generate_arbitrary_number_pattern_matcher - - -class TestGenerateArbitraryNumberPatternMatcher: - - @pytest.mark.parametrize( - "pattern_string, matching_strings, non_matching_strings", - [ - ("2x+3", ["2x+3", "5x+7"], ["2x-3", "2x+3+1"]), - # Regression test: the text following the last matched number - # (here the closing bracket ")") used to be inserted into the - # generated regex unescaped and unwrapped, which could produce - # an invalid pattern and raise `re.error: unbalanced parenthesis`. - ("(2x-5)(3x+2)", ["(2x-5)(3x+2)", "(7x-1)(4x+9)"], ["(3x+2)(2x-5)", "3x^2-11x-10"]), - ("(x-4)^2-5", ["(x-4)^2-5", "(x-6)^2-1"], ["(x-4)^2+5", "x^2-8x+11"]), - ] - ) - def test_matcher(self, pattern_string, matching_strings, non_matching_strings): - matcher = generate_arbitrary_number_pattern_matcher(pattern_string) - for matching_string in matching_strings: - assert matcher(matching_string) is True - for non_matching_string in non_matching_strings: - assert matcher(non_matching_string) is False diff --git a/app/utility/__init__.py b/app/utility/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/utility/criteria_graph_utilities.py b/app/utility/criteria_graph_utilities.py deleted file mode 100644 index b2d56d0..0000000 --- a/app/utility/criteria_graph_utilities.py +++ /dev/null @@ -1,374 +0,0 @@ -import json - -evaluation_style = ("([", "])") -starting_evaluation_style = (">", "]") -criterion_style = ("[", "]") -output_style = ("{{", "}}") -special_style = ("[[", "]]") - - -class CriteriaGraph: - - class Node: - def __init__(self, label, summary, details): - self.label = label - self.summary = summary - self.details = details - self.incoming = [] - self.outgoing = [] - return - - def __eq__(self, other): - return self.label == other.label and self.summary == other.summary and self.details == other.details - - class Evaluation(Node): - def __init__(self, label, summary, details, evaluate, replacement=None): - super().__init__(label, summary, details) - self.results = self.outgoing - self.evaluate = evaluate - self.replacement = replacement - return - - class Criterion(Node): - def __init__(self, label, summary, details, tags=None, feedback_string_generator=None): - super().__init__(label, summary, details) - self.consequences = self.outgoing - if feedback_string_generator is not None: - self.feedback_string_generator = feedback_string_generator - else: - self.feedback_string_generator = lambda x: None - if tags is None: - self.tags = set() - else: - self.tags = tags - return - - class Output(Node): - def __init__(self, label, summary, details, tags=None): - super().__init__(label, summary, details) - self.outgoing = [] - if tags is None: - self.tags = set() - else: - self.tags = tags - return - - class Edge: - def __init__(self, source, target): - self.source = source - self.target = target - return - - def __eq__(self, other): - return self.source.label == other.source.label and self.target.label == other.target.label - - def __hash__(self): - return (self.source.label, self.target.label).__hash__() - - RETURN = Output("RETURN", "RETURN", "Reached a previously visited node.") - END = Output("END", "END", "Evaluation completed.") - - class Tree(Node): - - def __init__(self, node, parent=None, outgoing=None, identifier=0, main_criteria=None): - super().__init__(node.label, node.summary, node.details) - self.identifier = "_"+str(identifier) - self.style = self.get_node_style(node) - self.type_label = self.get_node_type(node, main_criteria) - if parent is not None: - self.incoming = [parent] - if isinstance(node, CriteriaGraph.Output) or outgoing is None: - self.outgoing = [] - else: - self.outgoing = outgoing - return - - def get_node_type(self, node, main_criteria=None): - if main_criteria is None: - main_criteria = [] - if isinstance(node, CriteriaGraph.Evaluation): - type_label = "evaluation" - elif isinstance(node, CriteriaGraph.Criterion): - if node.label in main_criteria: - type_label = "main_criterion" - else: - type_label = "criterion" - elif isinstance(node, CriteriaGraph.Output): - type_label = "output" - else: - raise Exception("Cannot find style for this kind of node.") - return type_label - - def get_node_style(self, node): - if isinstance(node, CriteriaGraph.Evaluation): - style = evaluation_style - elif isinstance(node, CriteriaGraph.Criterion): - style = criterion_style - elif isinstance(node, CriteriaGraph.Output): - style = output_style - else: - raise Exception("Cannot find style for this kind of node.") - return style - - def mermaid(self, special_nodes=None): - if special_nodes is None: - special_nodes = [] - nodes = [self.label+self.identifier+('"'+self.summary+'"').join(starting_evaluation_style)] - edges = [self.label+self.identifier+" --> "+child.label+child.identifier for child in self.outgoing] - stack = self.outgoing - while len(stack) > 0: - child = stack.pop() - if child.label in special_nodes: - style = special_style - else: - style = child.style - nodes.append(child.label+child.identifier+('"'+child.summary+'"').join(style)) - edges += [child.label+child.identifier+" --> "+next_child.label+next_child.identifier for next_child in child.outgoing] - stack += child.outgoing - output = ["graph TD"]+nodes+edges - return "\n\t".join(output) - - def as_dictionary(self): - tree_dict = { - "label": self.label, - "summary": self.summary, - "details": self.details, - "type": self.type_label, - "children": [node.as_dictionary() for node in self.outgoing] - } - return tree_dict - - def json(self): - return str(json.dumps(self.as_dictionary())) - - def __init__(self, identifier, entry_evaluations=None): - self.identifier = identifier - self.evaluations = {} - self.criteria = {} - self.outputs = {} - self.sufficiencies = {} - self.entry_evaluations = entry_evaluations - return - - def json(self): - graph = { - "evaluations": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming], - "outgoing": [n.target.label for n in node.outgoing] - } for (label, node) in self.evaluations.items() - }, - "criteria": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming], - "outgoing": [n.target.label for n in node.outgoing] - } for (label, node) in self.criteria.items() - }, - "outputs": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming] - } for (label, node) in self.outputs.items() - }, - "sufficiencies": {label: list(suffs) for (label, suffs) in self.sufficiencies.items() if suffs is not None}, - } - return str(json.dumps(graph)) - - def mermaid(self): - output = ["flowchart TD"] - linebreak = '