From ecb5da2cd299cc0de90e426445f71c39bbd4ff53 Mon Sep 17 00:00:00 2001 From: KarlLundengaard Date: Fri, 16 May 2025 13:37:57 +0100 Subject: [PATCH] Restructured version of compareExpressions --- Dockerfile | 37 - README.md | 272 ++---- app/Dockerfile | 60 ++ {evaluation_function => app}/__init__.py | 0 app/context/physical_quantity.py | 605 +++++++++++++ app/context/symbolic.py | 792 ++++++++++++++++ app/docs/dev.md | 213 +++++ app/docs/user.md | 421 +++++++++ app/evaluation.py | 323 +++++++ app/evaluation_tests.py | 84 ++ .../feedback/__init__.py | 0 app/feedback/physical_quantity.py | 22 + app/feedback/symbolic.py | 71 ++ app/preview.py | 36 + app/preview_implementations/__init__.py | 0 .../physical_quantity_preview.py | 52 +- .../symbolic_preview.py | 56 +- {evaluation_function => app}/preview_tests.py | 10 +- {evaluation_function => app}/requirements.txt | 2 +- app/tests/example_tests.py | 512 +++++++++++ .../physical_quantity_evaluation_tests.py | 172 +++- .../tests/physical_quantity_preview_tests.py | 31 +- .../tests}/slr_quantity_tests.py | 82 +- .../tests/symbolic_evaluation_tests.py | 597 ++++++++++-- .../tests/symbolic_preview_tests.py | 44 +- .../utility}/criteria_graph_utilities.py | 143 +-- app/utility/criteria_parsing.py | 88 ++ .../utility/evaluation_result_utilities.py | 16 +- .../utility}/expression_utilities.py | 242 +++-- .../utility/physical_quantity_utilities.py | 350 ++------ app/utility/preview_utilities.py | 135 +++ .../utility}/slr_parsing_utilities.py | 51 +- .../syntactical_comparison_utilities.py | 96 ++ .../utility}/unit_system_conversions.py | 8 +- config.json | 4 +- docs/dev.md | 29 - docs/quantity_comparison_graph.svg | 478 ---------- docs/user.md | 138 --- evaluation_function/benchmarking.py | 57 -- evaluation_function/comparison_utilities.py | 21 - evaluation_function/criteria_utilities.py | 242 ----- evaluation_function/dev.py | 24 - evaluation_function/dev_test.py | 12 - evaluation_function/evaluation.py | 81 -- evaluation_function/evaluation_test.py | 73 -- evaluation_function/example_tests.py | 233 ----- .../feedback/quantity_comparison.py | 116 --- .../feedback/symbolic_comparison.py | 100 --- evaluation_function/main.py | 18 - evaluation_function/preview.py | 48 - evaluation_function/preview_utilities.py | 89 -- .../symbolic_comparison_evaluation.py | 847 ------------------ .../syntactical_comparison_utilities.py | 59 -- poetry.lock | 643 ------------- poetry.toml | 2 - pyproject.toml | 32 - 56 files changed, 4627 insertions(+), 4342 deletions(-) delete mode 100644 Dockerfile create mode 100644 app/Dockerfile rename {evaluation_function => app}/__init__.py (100%) create mode 100644 app/context/physical_quantity.py create mode 100644 app/context/symbolic.py create mode 100644 app/docs/dev.md create mode 100644 app/docs/user.md create mode 100644 app/evaluation.py create mode 100644 app/evaluation_tests.py rename {evaluation_function => app}/feedback/__init__.py (100%) create mode 100644 app/feedback/physical_quantity.py create mode 100644 app/feedback/symbolic.py create mode 100644 app/preview.py create mode 100644 app/preview_implementations/__init__.py rename evaluation_function/quantity_comparison_preview.py => app/preview_implementations/physical_quantity_preview.py (71%) rename evaluation_function/symbolic_comparison_preview.py => app/preview_implementations/symbolic_preview.py (70%) rename {evaluation_function => app}/preview_tests.py (88%) rename {evaluation_function => app}/requirements.txt (91%) create mode 100644 app/tests/example_tests.py rename evaluation_function/quantity_comparison_evaluation_tests.py => app/tests/physical_quantity_evaluation_tests.py (63%) rename evaluation_function/quantity_comparison_preview_tests.py => app/tests/physical_quantity_preview_tests.py (75%) rename {evaluation_function => app/tests}/slr_quantity_tests.py (87%) rename evaluation_function/symbolic_comparison_evaluation_tests.py => app/tests/symbolic_evaluation_tests.py (74%) rename evaluation_function/symbolic_comparison_preview_tests.py => app/tests/symbolic_preview_tests.py (85%) rename {evaluation_function => app/utility}/criteria_graph_utilities.py (72%) create mode 100644 app/utility/criteria_parsing.py rename evaluation_function/evaluation_response_utilities.py => app/utility/evaluation_result_utilities.py (81%) rename {evaluation_function => app/utility}/expression_utilities.py (74%) rename evaluation_function/slr_quantity.py => app/utility/physical_quantity_utilities.py (57%) create mode 100644 app/utility/preview_utilities.py rename {evaluation_function => app/utility}/slr_parsing_utilities.py (95%) create mode 100644 app/utility/syntactical_comparison_utilities.py rename {evaluation_function => app/utility}/unit_system_conversions.py (97%) delete mode 100644 docs/dev.md delete mode 100644 docs/quantity_comparison_graph.svg delete mode 100644 docs/user.md delete mode 100644 evaluation_function/benchmarking.py delete mode 100644 evaluation_function/comparison_utilities.py delete mode 100644 evaluation_function/criteria_utilities.py delete mode 100644 evaluation_function/dev.py delete mode 100644 evaluation_function/dev_test.py delete mode 100644 evaluation_function/evaluation.py delete mode 100644 evaluation_function/evaluation_test.py delete mode 100644 evaluation_function/example_tests.py delete mode 100644 evaluation_function/feedback/quantity_comparison.py delete mode 100644 evaluation_function/feedback/symbolic_comparison.py delete mode 100644 evaluation_function/main.py delete mode 100644 evaluation_function/preview.py delete mode 100644 evaluation_function/preview_utilities.py delete mode 100644 evaluation_function/symbolic_comparison_evaluation.py delete mode 100644 evaluation_function/syntactical_comparison_utilities.py delete mode 100644 poetry.lock delete mode 100644 poetry.toml delete mode 100644 pyproject.toml diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 6a65f10..0000000 --- a/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -FROM ghcr.io/lambda-feedback/evaluation-function-base/python:3.10 AS builder - -RUN pip install poetry==1.8.3 - -ENV POETRY_NO_INTERACTION=1 \ - POETRY_VIRTUALENVS_IN_PROJECT=1 \ - POETRY_VIRTUALENVS_CREATE=1 \ - POETRY_CACHE_DIR=/tmp/poetry_cache - -COPY pyproject.toml poetry.lock ./ - -RUN --mount=type=cache,target=$POETRY_CACHE_DIR \ - poetry install --without dev --no-root - -FROM ghcr.io/lambda-feedback/evaluation-function-base/python:3.10 - -ENV VIRTUAL_ENV=/app/.venv \ - PATH="/app/.venv/bin:$PATH" - -COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} - -# Precompile python files for faster startup -RUN python -m compileall -q . - -# Copy the evaluation function to the evaluation_function directory -COPY evaluation_function ./evaluation_function - -# Command to start the evaluation function with -ENV FUNCTION_COMMAND="python" - -# Args to start the evaluation function with -ENV FUNCTION_ARGS="-m,evaluation_function.main" - -# The transport to use for the RPC server -ENV FUNCTION_RPC_TRANSPORT="ipc" - -ENV LOG_LEVEL="debug" diff --git a/README.md b/README.md index bbb9693..db96321 100755 --- a/README.md +++ b/README.md @@ -1,238 +1,96 @@ -# Python Evaluation Function +# Evaluation Function Template Repository -This repository contains the boilerplate code needed to create a containerized evaluation function written in Python. +This template repository contains the boilerplate code needed in order to create an AWS Lambda function that can be written by any tutor to grade a response area in any way they like. -## Quickstart +This version is specifically for python, however the ultimate goal is to make similar boilerplate repositories in any language, allowing tutors the freedom to code in what they feel most comfortable with. -This chapter helps you to quickly set up a new Python evaluation function using this template repository. +## Table of Contents +- [Evaluation Function Template Repository](#evaluation-function-template-repository) + - [Table of Contents](#table-of-contents) + - [Repository Structure](#repository-structure) + - [Usage](#usage) + - [Getting Started](#getting-started) + - [How it works](#how-it-works) + - [Docker & Amazon Web Services (AWS)](#docker--amazon-web-services-aws) + - [Middleware Functions](#middleware-functions) + - [GitHub Actions](#github-actions) + - [Pre-requisites](#pre-requisites) + - [Contact](#contact) -> [!NOTE] -> After setting up the evaluation function, delete this chapter from the `README.md` file, and add your own documentation. - -#### 1. Create a new repository - -- In GitHub, choose `Use this template` > `Create a new repository` in the repository toolbar. - -- Choose the owner, and pick a name for the new repository. - - > [!IMPORTANT] - > If you want to deploy the evaluation function to Lambda Feedback, make sure to choose the Lambda Feedback organization as the owner. - -- Set the visibility to `Public` or `Private`. - - > [!IMPORTANT] - > If you want to use GitHub [deployment protection rules](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules), make sure to set the visibility to `Public`. - -- Click on `Create repository`. - -#### 2. Clone the new repository - -Clone the new repository to your local machine using the following command: +## Repository Structure ```bash -git clone +app/ + __init__.py + evaluation.py # Script containing the main evaluation_function + docs.md # Documentation page for this function (required) + evaluation_tests.py # Unittests for the main evaluation_function + requirements.txt # list of packages needed for algorithm.py + Dockerfile # for building whole image to deploy to AWS + +.github/ + workflows/ + test-and-deploy.yml # Testing and deployment pipeline + +config.json # Specify the name of the evaluation function in this file +.gitignore ``` -#### 3. Configure the evaluation function - -When deploying to Lambda Feedback, set the evaluation function name in the `config.json` file. Read the [Deploy to Lambda Feedback](#deploy-to-lambda-feedback) section for more information. - -#### 4. Develop the evaluation function - -You're ready to start developing your evaluation function. Head over to the [Development](#development) section to learn more. - -#### 5. Update the README - -In the `README.md` file, change the title and description so it fits the purpose of your evaluation function. - -Also, don't forget to delete the Quickstart chapter from the `README.md` file after you've completed these steps. - ## Usage -You can run the evaluation function either using [the pre-built Docker image](#run-the-docker-image) or build and run [the binary executable](#build-and-run-the-binary). - -### Run the Docker Image - -The pre-built Docker image comes with [Shimmy](https://github.com/lambda-feedback/shimmy) installed. - -> [!TIP] -> Shimmy is a small application that listens for incoming HTTP requests, validates the incoming data and forwards it to the underlying evaluation function. Learn more about Shimmy in the [Documentation](https://github.com/lambda-feedback/shimmy). - -The pre-built Docker image is available on the GitHub Container Registry. You can run the image using the following command: - -```bash -docker run -p 8080:8080 ghcr.io/lambda-feedback/evaluation-function-boilerplate-python:latest -``` - -### Run the Script - -You can choose between running the Python evaluation function itself, ore using Shimmy to run the function. - -**Raw Mode** - -Use the following command to run the evaluation function directly: - -```bash -python -m evaluation_function.main -``` - -This will run the evaluation function using the input data from `request.json` and write the output to `response.json`. - -**Shimmy** - -To have a more user-friendly experience, you can use [Shimmy](https://github.com/lambda-feedback/shimmy) to run the evaluation function. - -To run the evaluation function using Shimmy, use the following command: - -```bash -shimmy -c "python" -a "-m" -a "evaluation_function.main" -i ipc -``` - -## Development - -### Prerequisites - -- [Docker](https://docs.docker.com/get-docker/) -- [Python](https://www.python.org) - -### Repository Structure - -```bash -.github/workflows/ - build.yml # builds the public evaluation function image - deploy.yml # deploys the evaluation function to Lambda Feedback - -evaluation_function/main.py # evaluation function entrypoint -evaluation_function/evaluation.py # evaluation function implementation -evaluation_function/evaluation_test.py # evaluation function tests -evaluation_function/preview.py # evaluation function preview -evaluation_function/preview_test.py # evaluation function preview tests - -config.json # evaluation function deployment configuration file -``` - -### Development Workflow - -In its most basic form, the development workflow consists of writing the evaluation function in the `evaluation_function.wl` file and testing it locally. As long as the evaluation function adheres to the Evaluation Function API, a development workflow which incorporates using Shimmy is not necessary. - -Testing the evaluation function can be done by running the `dev.py` script using the Python interpreter like so: - -```bash -python -m evaluation_function.dev -``` - -> [!NOTE] -> Specify the `response` and `answer` as command-line arguments. - -### Building the Docker Image - -To build the Docker image, run the following command: - -```bash -docker build -t my-python-evaluation-function . -``` - -### Running the Docker Image - -To run the Docker image, use the following command: - -```bash -docker run -it --rm -p 8080:8080 my-python-evaluation-function -``` - -This will start the evaluation function and expose it on port `8080`. - -## Deployment - -This section guides you through the deployment process of the evaluation function. If you want to deploy the evaluation function to Lambda Feedback, follow the steps in the [Lambda Feedback](#deploy-to-lambda-feedback) section. Otherwise, you can deploy the evaluation function to other platforms using the [Other Platforms](#deploy-to-other-platforms) section. - -### Deploy to Lambda Feedback - -Deploying the evaluation function to Lambda Feedback is simple and straightforward, as long as the repository is within the [Lambda Feedback organization](https://github.com/lambda-feedback). - -After configuring the repository, a [GitHub Actions workflow](.github/workflows/deploy.yml) will automatically build and deploy the evaluation function to Lambda Feedback as soon as changes are pushed to the main branch of the repository. +### Getting Started -**Configuration** +1. Clone this repository +2. Change the name of the evaluation function in `config.json` +3. The name must be unique. To view existing grading functions, go to: -The deployment configuration is stored in the `config.json` file. Choose a unique name for the evaluation function and set the `EvaluationFunctionName` field in [`config.json`](config.json). + - [Staging API Gateway Integrations](https://eu-west-2.console.aws.amazon.com/apigateway/main/develop/integrations/attach?api=c1o0u8se7b®ion=eu-west-2&routes=0xsoy4q) + - [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) -> [!IMPORTANT] -> The evaluation function name must be unique within the Lambda Feedback organization, and must be in `lowerCamelCase`. You can find a example configuration below: +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. -```json -{ - "EvaluationFunctionName": "compareStringsWithPython" -} -``` - -### Deploy to other Platforms - -If you want to deploy the evaluation function to other platforms, you can use the Docker image to deploy the evaluation function. - -Please refer to the deployment documentation of the platform you want to deploy the evaluation function to. - -If you need help with the deployment, feel free to reach out to the Lambda Feedback team by creating an issue in the template repository. - -## FAQ +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! -### Pull Changes from the Template Repository +--- -If you want to pull changes from the template repository to your repository, follow these steps: +## How it works -1. Add the template repository as a remote: +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. -```bash -git remote add template https://github.com/lambda-feedback/evaluation-function-boilerplate-python.git -``` +### Docker & Amazon Web Services (AWS) -2. Fetch changes from all remotes: +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**. -```bash -git fetch --all -``` +Images are run within **containers** on AWS, which give us a lot of flexibility over what programming language and packages/libraries can be used. For more information on Docker, read this [introduction to containerisation](https://www.freecodecamp.org/news/a-beginner-friendly-introduction-to-containers-vms-and-docker-79a9e3e119b/). To learn more about AWS Lambda, click [here](https://geekflare.com/aws-lambda-for-beginners/). -3. Merge changes from the template repository: +### Middleware Functions +In order to run the algorithm and schema on AWS Lambda, some middleware functions have been provided to handle, validate and return the data so all you need to worry about is the evaluation script and testing. -```bash -git merge template/main --allow-unrelated-histories -``` +The code needed to build the image using all the middleware functions are available in the [BaseEvaluationFunctionLayer](https://github.com/lambda-feedback/BaseEvalutionFunctionLayer) repository. -> [!WARNING] -> Make sure to resolve any conflicts and keep the changes you want to keep. +### GitHub Actions +Whenever a commit is made to the GitHub repository, the new code will go through a pipeline, where it will be tested for syntax errors and code coverage. The pipeline used is called **GitHub Actions** and the scripts for these can be found in `.github/workflows/`. -## Troubleshooting +On top of that, when starting a new evaluation function, you will have to complete a set of unit test scripts, which not only make sure your code is reliable, but also helps you to build a _specification_ for how the code should function before you start programming. -### Containerized Evaluation Function Fails to Start +Once the code passes all these tests, it will then be uploaded to AWS and will be deployed and ready to go in only a few minutes. -If your evaluation function is working fine when run locally, but not when containerized, there is much more to consider. Here are some common issues and solution approaches: +## 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: -**Run-time dependencies** +- Python 3.8 or higher. -Make sure that all run-time dependencies are installed in the Docker image. +- GitHub Desktop or the `git` CLI. -- Python packages: Make sure to add the dependency to the `pyproject.toml` file, and run `poetry install` in the Dockerfile. -- System packages: If you need to install system packages, add the installation command to the Dockerfile. -- ML models: If your evaluation function depends on ML models, make sure to include them in the Docker image. -- Data files: If your evaluation function depends on data files, make sure to include them in the Docker image. +- A code editor such as Atom, VS Code, or Sublime. -**Architecture** - -Some package may not be compatible with the architecture of the Docker image. Make sure to use the correct platform when building and running the Docker image. - -E.g. to build a Docker image for the `linux/x86_64` platform, use the following command: - -```bash -docker build --platform=linux/x86_64 . -``` - -**Verify Standalone Execution** - -If requests are timing out, it might be due to the evaluation function not being able to run. Make sure that the evaluation function can be run as a standalone script. This will help you to identify issues that are specific to the containerized environment. - -To run just the evaluation function as a standalone script, without using Shimmy, use the following command: - -```bash -docker run -it --rm my-python-evaluation-function python -m evaluation_function.main -``` +Copy this template over by clicking **Use this template** button found in the repository on GitHub. Save it to the `lambda-feedback` Organisation. -If the command starts without any errors, the evaluation function is working correctly. If not, you will see the error message in the console. +## Contact diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..035b124 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,60 @@ +# Base image that bundles AWS Lambda Python 3.8 image with some middleware functions +# FROM base-eval-tmp +FROM rabidsheep55/python-base-eval-layer + +RUN yum install -y git + +WORKDIR /app + +# Copy and install any packages/modules needed for your evaluation script. +COPY requirements.txt . +RUN pip3 install -r requirements.txt + +# Copy main scripts +COPY evaluation.py ./app/ +COPY evaluation_tests.py ./app/ +COPY preview.py ./app/ +COPY preview_tests.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_tests.py ./app/tests/ +COPY tests/physical_quantity_evaluation_tests.py ./app/tests/ +COPY tests/physical_quantity_preview_tests.py ./app/tests/ +COPY tests/slr_quantity_tests.py ./app/tests/ +COPY tests/symbolic_evaluation_tests.py ./app/tests/ +COPY tests/symbolic_preview_tests.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 +COPY docs/quantity_comparison_graph.svg ./app/docs/quantity_comparison_graph.svg + +# Set permissions so files and directories can be accessed on AWS +RUN chmod 644 $(find . -type f) +RUN chmod 755 $(find . -type d) + +# The entrypoint for AWS is to invoke the handler function within the app package +CMD [ "/app/app.handler" ] diff --git a/evaluation_function/__init__.py b/app/__init__.py similarity index 100% rename from evaluation_function/__init__.py rename to app/__init__.py diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py new file mode 100644 index 0000000..864b294 --- /dev/null +++ b/app/context/physical_quantity.py @@ -0,0 +1,605 @@ +# ------- +# IMPORTS +# ------- + +import re +from copy import deepcopy +from ..utility.physical_quantity_utilities import ( + SLR_quantity_parser, + SLR_quantity_parsing +) +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, + compute_relative_tolerance_from_significant_decimals, + 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 sympy import Symbol + +from ..utility.criteria_graph_utilities import CriteriaGraph + + +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: + standard_value = None + if quantity.value is not None: + value = parse_expression(quantity.value.content_string(), quantity.parsing_params) + else: + value = None + if quantity.standard_unit is not None: + standard_unit = quantity.standard_unit + else: + standard_unit = None + if quantity.unit is not None: + unit = parse_expression(quantity.unit.content_string(), quantity.parsing_params) + else: + unit = None + quantity_dict = { + "quantity": quantity, + "standard": { + "value": standard_value, + "unit": standard_unit + }, + "dimension": quantity.dimension, + "original": { + "value": value, + "unit": unit + }, + } + return quantity_dict + + +def generate_criteria_parser(reserved_expressions): + start_symbol = "START" + end_symbol = "END" + null_symbol = "NULL" + + criteria_operations = { + "matches", + "dimension", + "=", + "<=", + ">=", + "<", + ">", + } + + token_list = [ + (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), + ] + token_list += [(" *"+x+" *", " "+x+" ") for x in criteria_operations] + + productions = [ + ("START", "BOOL", create_node), + ("BOOL", "QUANTITY matches QUANTITY", infix), + ("BOOL", "DIMENSION matches DIMENSION", infix), + ("BOOL", "QUANTITY=QUANTITY", infix), + ("BOOL", "QUANTITY<=QUANTITY", infix), + ("BOOL", "QUANTITY>=QUANTITY", infix), + ("BOOL", "QUANTITYQUANTITY", infix), + ("QUANTITY", "INPUT", create_node), + ("DIMENSION", "dimension(QUANTITY)", operate(1)), + ] + + return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) + + +def comparison_function(comparison_operator): + none_placeholder = Symbol('NONE_PLACEHOLDER') + comparison_type_dictionary = { + "=": lambda a, b: bool(a == b), + ">": lambda a, b: bool(a > b), + "<": lambda a, b: bool(a < b), + ">=": lambda a, b: bool(a >= b), + "<=": lambda a, b: bool(a <= b), + } + comparison = comparison_type_dictionary[comparison_operator] + + def comparison_function_inner(lhs, rhs, substitutions): + local_substitutions = [(key, none_placeholder) if expr is None else (key, expr) for (key, expr) in substitutions] + expr0 = lhs.subs(local_substitutions) + expr1 = rhs.subs(local_substitutions) + result = comparison((expr0-expr1).cancel().simplify().simplify(), 0) + return result + return comparison_function_inner + + +def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): + graph = CriteriaGraph(label) + END = CriteriaGraph.END + graph.add_node(END) + 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}) + + if label is None: + label = criterion.content_string() + + inputs = criterion.children + lhs_string = criterion.children[0].content_string() + rhs_string = criterion.children[1].content_string() + lhs = parse_expression(lhs_string, parsing_params) + rhs = parse_expression(rhs_string, parsing_params) + + stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions] + compare = comparison_function(comparison_operator) + + def compare_evaluate(unused_input): + if compare(lhs, rhs, stardard_forms) is True: + return {label+"_TRUE": None} + else: + return {label+"_FALSE": None} + + graph.add_evaluation_node( + label, + summary=f"Is {inputs[0]} {comparison_operator} {inputs[1]}?", + details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", + evaluate=compare_evaluate + ) + graph.attach( + label, + label+"_TRUE", + summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", + details=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", + feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE") + ) + graph.attach(label+"_TRUE", END.label) + graph.attach( + label, + label+"_FALSE", + summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", + details=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", + feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("FALSE") + ) + graph.attach(label+"_FALSE", END.label) + # TODO: Consider adding node for cases where comparison cannot be done / cannot be determined + + return graph + + +def greater_than_node(criterion, parameters, label=None): + return comparison_base_graph(criterion, parameters, comparison_operator=">", label=label) + + +def greater_than_or_equal_node(criterion, parameters, label=None): + # TODO: Add nodes for the equal case + graph = comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) + return graph + + +def less_than_node(criterion, parameters, label=None): + return comparison_base_graph(criterion, parameters, comparison_operator="<", label=label) + + +def less_than_or_equal_node(criterion, parameters, label=None): + # TODO: Add nodes for the equal case + graph = comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label) + return graph + + +def criterion_match_node(criterion, parameters, label=None): + graph = CriteriaGraph(label) + END = CriteriaGraph.END + graph.add_node(END) + 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}) + + if label is None: + label = criterion.content_string() + + inputs = criterion.children + lhs_string = criterion.children[0].content_string() + rhs_string = criterion.children[1].content_string() + lhs = parse_expression(lhs_string, parsing_params) + rhs = parse_expression(rhs_string, parsing_params) + + is_equal = comparison_function("=") + + def is_proportional(lhs, rhs, substitutions): + none_placeholder = Symbol('NONE_PLACEHOLDER') + local_substitutions = [(key, none_placeholder) if expr is None else (key, expr) for (key, expr) in substitutions] + expr0 = lhs.subs(local_substitutions) + expr1 = rhs.subs(local_substitutions) + if expr0.cancel().simplify().simplify() != 0: + result = (expr0/expr1).cancel().simplify().is_constant() + ratio = expr0/expr1 + elif expr0.cancel().simplify().simplify() != 0: + result = (expr1/expr0).cancel().simplify().is_constant() + ratio = expr1/expr0 + else: + result = False + if result is True: + ratio = float(ratio.cancel().simplify()) + else: + ratio = None + return result, ratio + + def quantity_match(unused_inputs): + # TODO: Better system for identifying if we expect the response to have value/unit or not + if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string): + res_value = parameters["reserved_expressions"]["response"]["original"]["value"] + ans_value = parameters["reserved_expressions"]["answer"]["original"]["value"] + res_unit = parameters["reserved_expressions"]["response"]["original"]["unit"] + ans_unit = parameters["reserved_expressions"]["answer"]["original"]["unit"] + if res_value is None and ans_value is not None: + return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} + if res_value is not None and ans_value is None: + return {label+"_UNEXPECTED_VALUE": {"lhs": lhs_string, "rhs": rhs_string}} + if res_unit is None and ans_unit is not None: + return {label+"_MISSING_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} + if res_unit is not None and ans_unit is None: + return {label+"_UNEXPECTED_UNIT": {"lhs": lhs_string, "rhs": rhs_string}} + + substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions] + value_match = is_equal(lhs, rhs, substitutions) + + if value_match is False: + # TODO: better analysis of where `answer` is found in the criteria so that + # numerical tolerances can be applied appropriately + if parsing_params.get('rtol', 0) > 0 or parsing_params.get('atol', 0) > 0: + if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): + ans = parameters["reserved_expressions"]["answer"]["standard"]["value"] + res = parameters["reserved_expressions"]["response"]["standard"]["value"] + 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: + value_match = bool(abs(float((ans-res)/ans)) < parsing_params['rtol']) + elif parsing_params.get('atol', 0) > 0: + value_match = bool(abs(float(ans-res)) < parsing_params['atol']) + + substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] + unit_match = is_equal(lhs, rhs, substitutions) + + output_tags = None + if value_match is True and unit_match is True: + output_tags = { + label+"_TRUE": {"lhs": lhs_string, "rhs": rhs_string} + } + else: + output_tags = { + label+"_FALSE": {"lhs": lhs_string, "rhs": rhs_string} + } + + return output_tags + + def dimension_match(unused_inputs): + substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions] + dimension_match, _ = is_proportional(lhs, rhs, substitutions) + + output_tags = None + if dimension_match is True: + output_tags = { + label+"_DIMENSION_MATCH"+"_TRUE": {"lhs": lhs_string, "rhs": rhs_string} + } + else: + output_tags = { + label+"_DIMENSION_MATCH"+"_FALSE": {"lhs": lhs_string, "rhs": rhs_string} + } + return output_tags + + def unit_comparison(unused_inputs): + substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions] + + if is_equal(lhs, rhs, substitutions) is True: + output_tags = { + label+"_UNIT_COMPARISON"+"_IDENTICAL": None + } + else: + local_substitutions = [(key, expr["quantity"].expanded_unit) for (key, expr) in reserved_expressions] + result, ratio = is_proportional(lhs, rhs, local_substitutions) + if result is True and ratio >= 1000: + output_tags = { + label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE": None + } + elif result is True and ratio <= 1/1000: + output_tags = { + label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL": None + } + else: + output_tags = { + label+"_UNIT_COMPARISON"+"_SIMILAR": None + } + + return output_tags + + graph.add_evaluation_node( + label, + summary=f"Do the quantities {inputs[0]} and {inputs[1]} match?", + details=f"Converts {inputs[0]} and {inputs[1]} match to a common set of base units and compares their values.", + evaluate=quantity_match + ) + graph.attach( + 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( + 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( + label, + label+"_MISSING_VALUE", + summary="The response is missing a value.", + details="The response is missing a value.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE") + ) + graph.attach(label+"_MISSING_VALUE", END.label) + graph.attach( + label, + label+"_UNEXPECTED_VALUE", + summary="The response is expected only have unit(s), no value.", + details="The response is expected only have unit(s), no value.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE") + ) + graph.attach(label+"_UNEXPECTED_VALUE", END.label) + graph.attach( + label, + label+"_MISSING_UNIT", + summary="The response is missing unit(s).", + details="The response is missing unit(s).", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT") + ) + graph.attach(label+"_MISSING_UNIT", END.label) + graph.attach( + label, + label+"_UNEXPECTED_UNIT", + summary="The response is expected to be a value without unit(s).", + details="The response is expected to be a value without unit(s).", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") + ) + graph.attach(label+"_UNEXPECTED_UNIT", END.label) + graph.attach( + 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( + 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( + label+"_DIMENSION_MATCH", + label+"_DIMENSION_MATCH"+"_FALSE", + summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", + details=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MISMATCH") + ) + graph.attach(label+"_DIMENSION_MATCH"+"_TRUE", END.label) + graph.attach(label+"_DIMENSION_MATCH"+"_FALSE", END.label) + graph.attach( + 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( + 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( + 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( + label+"_UNIT_COMPARISON", + label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", + summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", + details=f"The units of {inputs[0]} are at least 1000 times greater than the units of {inputs[1]}.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE") + ) + graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", END.label) + graph.attach( + label+"_UNIT_COMPARISON", + label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", + summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", + details=f"The units of {inputs[0]} are at least 1000 times smaller than the units of {inputs[1]}.", + feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_SMALL") + ) + graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", END.label) + + return graph + + +def feedback_procedure_generator(parameters_dict): + graphs = dict() + for (label, criterion) in parameters_dict["criteria"].items(): + graph_templates = { + "matches": criterion_match_node, + ">": greater_than_node, + ">=": greater_than_or_equal_node, + "<": less_than_node, + "<=": less_than_or_equal_node, + } + graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node) + graph = graph_template(criterion, parameters_dict) + for evaluation in graph.evaluations.values(): + if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): + evaluation.replacement = CriteriaGraph.END + graphs.update({label: graph}) + return graphs + + +def expression_preprocess(name, expr, parameters): + if parameters.get("strictness", "natural") == "legacy": + prefix_data = {(p[0], p[1], tuple(), p[3]) for p in set_of_SI_prefixes} + prefixes = [] + for prefix in prefix_data: + prefixes = prefixes+[prefix[0]] + list(prefix[-1]) + prefix_short_forms = [prefix[1] for prefix in prefix_data] + unit_data = 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 + unit_long_forms = prefixes + for unit in unit_data: + unit_long_forms = unit_long_forms+[unit[0]] + list(unit[-2]) + list(unit[-1]) + unit_long_forms = "("+"|".join(unit_long_forms)+")" + # Rewrite any expression on the form "*UNIT" (but not "**UNIT") as " UNIT" + # Example: "newton*metre" ---> "newton metre" + search_string = r"(? "kilometre" + search_string = prefixes+" "+unit_long_forms + match_content = re.search(search_string, expr) + while match_content is not None: + expr = expr[0:match_content.span()[0]]+" "+"".join(match_content.group().split())+expr[match_content.span()[1]:] + match_content = re.search(search_string, expr) + unit_short_forms = [u[1] for u in unit_data] + short_forms = "("+"|".join(list(set(prefix_short_forms+unit_short_forms)))+")" + # Add space before short forms of prefixes or unit names if they are preceded by numbers or multiplication + # Example: "100Pa" ---> "100 Pa" + search_string = r"[0-9\*\(\)]"+short_forms + match_content = re.search(search_string, expr) + while match_content is not None: + expr = expr[0:match_content.span()[0]+1]+" "+expr[match_content.span()[0]+1:] + match_content = re.search(search_string, expr) + # Remove space after prefix short forms if they are preceded by numbers, multiplication or space + # Example: "100 m Pa" ---> "100 mPa" + prefix_short_forms = "("+"|".join(prefix_short_forms)+")" + search_string = r"[0-9\*\(\) ]"+prefix_short_forms+" " + match_content = re.search(search_string, expr) + while match_content is not None: + expr = expr[0:match_content.span()[0]+1]+match_content.group()[0:-1]+expr[match_content.span()[1]:] + match_content = re.search(search_string, expr) + # Remove multiplication and space after prefix short forms if they are preceded by numbers, multiplication or space + # Example: "100 m* Pa" ---> "100 mPa" + search_string = r"[0-9\*\(\) ]"+prefix_short_forms+"\* " + match_content = re.search(search_string, expr) + while match_content is not None: + expr = expr[0:match_content.span()[0]+1]+match_content.group()[0:-2]+expr[match_content.span()[1]:] + match_content = re.search(search_string, expr) + # Replace multiplication followed by space before unit short forms with only spaces if they are preceded by numbers or space + # Example: "100* Pa" ---> "100 Pa" + unit_short_forms = "("+"|".join(unit_short_forms)+")" + search_string = r"[0-9\(\) ]\* "+unit_short_forms + match_content = re.search(search_string, expr) + while match_content is not None: + expr = expr[0:match_content.span()[0]]+match_content.group().replace("*", " ")+expr[match_content.span()[1]:] + match_content = re.search(search_string, expr) + + prefixes = set(x[0] for x in set_of_SI_prefixes) + fundamental_units = set(x[0] for x in set_of_SI_base_unit_dimensions) + units_string = parameters["units_string"] + valid_units = set() + for key in units_sets_dictionary.keys(): + if key in units_string: + for unit in units_sets_dictionary[key]: + valid_units = valid_units.union(set((unit[0], unit[1])+unit[3]+unit[4])) + dimensions = set(x[2] for x in set_of_SI_base_unit_dimensions) + unsplittable_symbols = list(prefixes | fundamental_units | valid_units | dimensions) + preprocess_parameters = deepcopy(parameters) + # TODO: find better way to prevent preprocessing from mangling reserved keywords for physical quantity criteria + preprocess_parameters.update({"reserved_keywords": preprocess_parameters.get("reserved_keywords", [])+unsplittable_symbols+['matches']}) + expr = substitute_input_symbols(expr.strip(), preprocess_parameters)[0] + success = True + return success, expr, None + + +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()): + parsing_parameters = create_sympy_parsing_params(params) + parsing_parameters.update({ + "strictness": params.get("strictness", "natural") + }) + return parsing_parameters + + +# CONSIDER: Move these to separate file so that they can be shared with +# the preview function implementation (or move preview implementation here) +default_parameters = deepcopy(symbolic_default_parameters) +default_parameters.update( + { + "physical_quantity": True, + "strictness": "natural", + "units_string": "SI common imperial", + } +) + + +default_criteria = ["response matches answer"] + + +context = { + "expression_preview": preview_function, + "generate_criteria_parser": generate_criteria_parser, + "expression_preprocess": expression_preprocess, + "expression_parse": parse_quantity, + "default_parameters": default_parameters, + "default_criteria": default_criteria, + "feedback_procedure_generator": feedback_procedure_generator, + "feedback_string_generator": feedback_string_generator, + "parsing_parameters_generator": parsing_parameters_generator, +} diff --git a/app/context/symbolic.py b/app/context/symbolic.py new file mode 100644 index 0000000..6962b2d --- /dev/null +++ b/app/context/symbolic.py @@ -0,0 +1,792 @@ +from copy import deepcopy +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, + parse_expression, + create_sympy_parsing_params, + preprocess_expression, +) + +from ..preview_implementations.symbolic_preview import preview_function +from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators + +from ..utility.syntactical_comparison_utilities import patterns as syntactical_forms +from ..utility.syntactical_comparison_utilities import generate_arbitrary_number_pattern_matcher + +from ..utility.criteria_parsing import generate_criteria_parser +from ..utility.criteria_graph_utilities import CriteriaGraph + + +def expression_preprocess(name, expr, parameters): + return preprocess_expression(name, expr, parameters) + + +def expression_parse(name, expr, parameters, evaluation_result): + return parse_expression(expr, parameters) + + +default_criteria = ["response = answer"] + + +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}) + if label in {"EQUALITY", "WRITTEN_AS"}: + result = check_equality(criterion, parameters_dict) + elif label == "ORDER": + result = check_order(criterion, parameters_dict) + elif label == "CONTAINS": + result = check_contains_symbol(criterion, parameters_dict) + elif label == "WHERE": + crit = criterion.children[0] + subs = criterion.children[1] + local_subs = [] + if subs.label == "EQUALITY": + subs = [subs] + elif subs.label == "SEPARATOR": + subs = subs.children + for sub in subs: + name = sub.children[0].content_string() + expr = parse_expression(sub.children[1].content_string(), parsing_params) + local_subs.append((name, expr)) + result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}, generate_feedback) + else: + raise Exception(label) + return result + + +def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): + parsing_params = deepcopy(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())), + } + ) + 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): + simplified_lhs_expr = lhs_expr.cancel().simplify() + simplified_rhs_expr = rhs_expr.cancel().simplify() + if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): + lhs_expr = real_part(simplified_lhs_expr) + I*im(simplified_lhs_expr) + rhs_expr = real_part(simplified_rhs_expr) + I*im(simplified_rhs_expr) + return lhs_expr, rhs_expr + + +def do_comparison(comparison_symbol, expression): + comparisons = { + "=": lambda expr: bool(expression.cancel().simplify().simplify() == 0), + ">": lambda expr: bool(expression.cancel().simplify().simplify() > 0), + ">=": lambda expr: bool(expression.cancel().simplify().simplify() >= 0), + "<": lambda expr: bool(expression.cancel().simplify().simplify() < 0), + "<=": lambda expr: bool(expression.cancel().simplify().simplify() <= 0), + } + comparison = comparisons[comparison_symbol.strip()] + result = comparison(expression) + return result + + +def check_equality(criterion, parameters_dict, local_substitutions=[]): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + result = do_comparison(criterion.content, lhs_expr-rhs_expr) + + # TODO: Make numerical comparison its own context + if result is False: + error_below_rtol = None + error_below_atol = None + if parameters_dict.get("numerical", False) or float(parameters_dict.get("rtol", 0)) > 0 or float(parameters_dict.get("atol", 0)) > 0: + # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. + # The two lines below this comments fixes the issue but a more robust solution should be found for cases where there + # are other reserved symbols. + def replace_pi(expr): + pi_symbol = pi + for s in expr.free_symbols: + if str(s) == 'pi': + pi_symbol = s + return expr.subs(pi_symbol, float(pi)) + # NOTE: This code assumes that the left hand side is the response and the right hand side is the answer + # Separates LHS and RHS, parses and evaluates them + res = N(replace_pi(lhs_expr)) + ans = N(replace_pi(rhs_expr)) + if float(parameters_dict.get("atol", 0)) > 0: + try: + absolute_error = abs(float(ans-res)) + error_below_atol = bool(absolute_error < float(parameters_dict["atol"])) + except TypeError: + error_below_atol = None + else: + error_below_atol = True + if float(parameters_dict.get("rtol", 0)) > 0: + try: + relative_error = abs(float((ans-res)/ans)) + error_below_rtol = bool(relative_error < float(parameters_dict["rtol"])) + except TypeError: + error_below_rtol = None + else: + error_below_rtol = True + if error_below_atol is None or error_below_rtol is None: + result = False + elif error_below_atol is True and error_below_rtol is True: + result = True + + return result + + +def check_order(criterion, parameters_dict, local_substitutions=[]): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + result = do_comparison(criterion.content, lhs_expr-rhs_expr) + return result + + +def check_contains_symbol(criterion, parameters_dict, local_substitutions=[]): + lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions) + result = rhs_expr in lhs_expr.atoms() + return result + + +def find_coords_for_node_type(expression, node_type): + stack = [(expression, tuple())] + node_coords = [] + while len(stack) > 0: + (expr, coord) = stack.pop() + if isinstance(expr, node_type): + node_coords.append(coord) + for (k, arg) in enumerate(expr.args): + stack.append((arg, coord+(k,))) + return node_coords + + +def replace_node_variations(expression, type_of_node, replacement_function): + variations = [] + list_of_coords = find_coords_for_node_type(expression, type_of_node) + for coords in list_of_coords: + nodes = [expression] + for coord in coords: + nodes.append(nodes[-1].args[coord]) + for k in range(0, len(nodes[-1].args)): + variation = replacement_function(nodes[-1], k) + for (node, coord) in reversed(list(zip(nodes, coords))): + new_args = node.args[0:coord]+(variation,)+node.args[coord+1:] + variation = type(node)(*new_args) + variations.append(variation) + return variations + + +def one_addition_to_subtraction(expression): + def addition_to_subtraction(node, k): + return node - 2*node.args[k] + variations = replace_node_variations(expression, Add, addition_to_subtraction) + return variations + + +def one_swap_addition_and_multiplication(expression): + + def addition_to_multiplication(node, k): + return node - node.args[k-1] - node.args[k] + node.args[k-1] * node.args[k] + + def multiplication_to_addition(node, k): + return node - 2*node.args[k] + + variations = replace_node_variations(expression, Add, addition_to_multiplication) + variations += replace_node_variations(expression, Mul, addition_to_multiplication) + + return variations + + +def one_exponent_flip(expression): + def exponent_flip(node, k): + return node**(-1) + variations = replace_node_variations(expression, Pow, exponent_flip) + return variations + + +def criterion_equality_node(criterion, parameters_dict, label=None): + if label is None: + label = criterion.content_string() + + def mathematical_equivalence(unused_input): + result = check_equality(criterion, parameters_dict) + if result is True: + return { + label+"_TRUE": None + } + else: + return { + label+"_FALSE": None + } + + def set_equivalence(unused_input): + matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} + for i, response in enumerate(response_list): + result = None + for j, answer in enumerate(answer_list): + current_pair = [("response", response), ("answer", answer)] + result = check_equality(criterion, parameters_dict, local_substitutions=current_pair) + if result is True: + matches["responses"][i] = True + matches["answers"][j] = True + if parameters_dict["multiple_answers_criteria"] == "all": + is_correct = all(matches["responses"]) and all(matches["answers"]) + if is_correct is False: + return { + label+"_MULTIPLE_ANSWER_FAIL_ALL": None + } + elif parameters_dict["multiple_answers_criteria"] == "all_responses": + is_correct = all(matches["responses"]) + if is_correct is False: + return { + label+"_MULTIPLE_ANSWER_FAIL_RESPONSE": None + } + elif parameters_dict["multiple_answers_criteria"] == "all_answers": + is_correct = all(matches["answers"]) + if is_correct is False: + return { + label+"_MULTIPLE_ANSWER_FAIL_ANSWER": None + } + else: + raise SyntaxWarning(f"Unknown multiple_answers_criteria: {parameters_dict['multiple_answers_critera']}") + return { + label+"_TRUE": None + } + + def equality_equivalence(unused_input): + result = False + res = parameters_dict["reserved_expressions"]["response"] + ans = parameters_dict["reserved_expressions"]["answer"] + + if (not isinstance(res, Equality)) and isinstance(ans, Equality): + return { + label+"_EXPRESSION_NOT_EQUALITY": None + } + + if isinstance(res, Equality) and (not isinstance(ans, Equality)): + return { + label+"_EQUALITY_NOT_EXPRESSION": None + } + + # TODO: Remove when criteria for checking proportionality is implemented + if isinstance(res, Equality) and isinstance(ans, Equality): + symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols + result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"]["constants"]) + if result is True: + return { + label+"_TRUE": None + } + else: + return { + label+"_FALSE": None + } + + graph = CriteriaGraph(label) + END = CriteriaGraph.END + graph.add_node(END) + lhs = criterion.children[0].content_string() + rhs = criterion.children[1].content_string() + + def same_symbols(unused_input): + parsing_params = deepcopy(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())), + } + ) + lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) + rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) + result = lsym.free_symbols == rsym.free_symbols + if result is True: + return { + label+"_SAME_SYMBOLS"+"_TRUE": None + } + else: + return { + label+"_SAME_SYMBOLS"+"_FALSE": None + } + + use_set_equivalence = False + response_list = parameters_dict["reserved_expressions"]["response"] + answer_list = parameters_dict["reserved_expressions"]["answer"] + if isinstance(response_list, set) and isinstance(answer_list, set): + use_set_equivalence = True + elif isinstance(response_list, set) and not isinstance(answer_list, set): + use_set_equivalence = True + answer_list = set([answer_list]) + elif not isinstance(response_list, set) and isinstance(answer_list, set): + use_set_equivalence = True + response_list = set([response_list]) + + res = parameters_dict["reserved_expressions"]["response"] + ans = parameters_dict["reserved_expressions"]["answer"] + use_equality_equivalence = isinstance(res, Equality) or isinstance(ans, Equality) + + # TODO: Make checking set quivalence its own context that calls symbolic comparisons instead + if use_set_equivalence is True: + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+"="+str(rhs)+".", + evaluate=set_equivalence + ) + graph.attach( + 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( + label, + label+"_MULTIPLE_ANSWER_FAIL_ALL", + summary=str(lhs)+" is not equal to "+str(rhs), + details="At least one answer or response was incorrect.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") + ) + graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label) + graph.attach( + label, + label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", + summary="Unexpected element in response.", + details="At least one response was incorrect.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE") + ) + graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label) + graph.attach( + label, + label+"_MULTIPLE_ANSWER_FAIL_ANSWER", + summary="Missing element in response", + details="At least one answer is missing in the response.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ANSWER") + ) + graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ANSWER", END.label) + elif use_equality_equivalence is True: + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", + evaluate=equality_equivalence + ) + graph.attach( + label, + label+"_TRUE", + summary=str(lhs)+" is equivalent to "+str(rhs), + details=str(lhs)+" is equivalent to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") + ) + graph.attach(label+"_TRUE", END.label) + graph.attach( + label, + label+"_FALSE", + summary=str(lhs)+" is not equivalent to "+str(rhs), + details=str(lhs)+" is not equivalent to "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") + ) + graph.attach(label+"_FALSE", END.label) + graph.attach( + label, + label+"_EXPRESSION_NOT_EQUALITY", + summary=str(lhs)+" is an expression, not an equality.", + details=str(lhs)+" is an expression, not an equality.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") + ) + graph.attach(label+"_EXPRESSION_NOT_EQUALITY", END.label) + graph.attach( + label, + label+"_EQUALITY_NOT_EXPRESSION", + summary=str(lhs)+" is an equality, not an expression.", + details=str(lhs)+" is an equality, not an expression.", + feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_NOT_EXPRESSION") + ) + graph.attach(label+"_EQUALITY_NOT_EXPRESSION", END.label) + else: + graph.add_evaluation_node( + label, + summary=label, + details="Checks if "+str(lhs)+"="+str(rhs)+".", + evaluate=mathematical_equivalence + ) + graph.attach( + 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( + 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( + label+"_SAME_SYMBOLS", + label+"_SAME_SYMBOLS"+"_TRUE", + summary=str(lhs)+" has the same symbols as "+str(rhs), + details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") + ) + graph.attach(label+"_SAME_SYMBOLS"+"_TRUE", END.label) + graph.attach( + label+"_SAME_SYMBOLS", + label+"_SAME_SYMBOLS"+"_FALSE", + summary=str(lhs)+" does not have the same symbols as "+str(rhs), + details=str(lhs)+" does note have the same (free) symbols as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") + ) + graph.attach(label+"_SAME_SYMBOLS"+"_FALSE", END.label) + graph.attach( + label, + label+"_FALSE", + summary=str(lhs)+"=\\="+str(rhs), + details=str(lhs)+" is not equal to"+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") + ) + graph.attach(label+"_FALSE", END.label) + return graph + + +def criterion_written_as_node(criterion, parameters_dict, label=None): + if label is None: + label = criterion.content_string() + + def written_as_known_form(string, name): + def find_form(unused_input): + forms = set() + for form_label in syntactical_forms.keys(): + if syntactical_forms[form_label]["matcher"](string) is True: + forms.add(form_label) + if len(forms) > 0: + return {name+"_WRITTEN_AS_"+form_label: None for form_label in forms} + else: + return {name+"_WRITTEN_AS_UNKNOWN": None} + return find_form + + def compare_to_form(string, form_label): + def inner(unused_input): + result = dict() + if syntactical_forms[form_label]["matcher"](string) is True: + result.update({label+"_TRUE": None}) + else: + result.update({label+"_FALSE": None}) + return result + return inner + + graph = CriteriaGraph(label) + END = CriteriaGraph.END + graph.add_node(END) + + lhs = criterion.children[0].content_string() + rhs = criterion.children[1].content_string() + + if set([lhs, rhs]) == set(["response", "answer"]): + + # TODO: Make a version that can compare the forms of any two reserved expressions + answer = parameters_dict["reserved_expressions_strings"]["task"]["answer"] + response = parameters_dict["reserved_expressions_strings"]["learner"]["response"] + + def response_written_as_answer(unused_input): + matcher = generate_arbitrary_number_pattern_matcher(answer) + if matcher(response): + return {label+"_TRUE": None} + else: + return {label+"_FALSE": None} + + graph.add_evaluation_node( + str(rhs)+"_WRITTEN_AS_KNOWN_FORM", + summary=label, + details=f"Checks if {rhs} is written in a known form.", + evaluate=written_as_known_form(answer, rhs) + ) + graph.attach( + 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( + str(rhs)+"_WRITTEN_AS_UNKNOWN", + label, + summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".", + details="Checks if "+str(lhs)+" is written in the same form as "+str(rhs)+".", + evaluate=response_written_as_answer, + sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"} + ) + graph.attach( + 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( + label, + label+"_FALSE", + summary=str(rhs)+" written in the same form as "+str(rhs)+".", + details=str(rhs)+" written in the same form as "+str(rhs)+".", + feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("FALSE") + ) + + for form_label in syntactical_forms.keys(): + graph.attach( + str(rhs)+"_WRITTEN_AS_KNOWN_FORM", + str(rhs)+"_WRITTEN_AS_"+form_label, + summary=str(rhs)+" in written in "+syntactical_forms[form_label]["name"]+" form.", + details=str(rhs)+" in written in "+syntactical_forms[form_label]["name"]+" form.", + feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"](form_label), + ) + graph.attach( + str(rhs)+"_WRITTEN_AS_"+form_label, + str(lhs)+"_COMPARE_FORM_TO_"+form_label, + summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label]["name"]+" form.", + details="Checks if "+str(lhs)+" is written in "+syntactical_forms[form_label]["name"]+" form.", + evaluate=compare_to_form(response, form_label), + sufficiencies={str(rhs)+"_WRITTEN_AS_"+form_label} + ) + graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_TRUE") + graph.attach(str(lhs)+"_COMPARE_FORM_TO_"+form_label, label+"_FALSE") + return graph + + +def criterion_where_node(criterion, parameters_dict, label=None): + parsing_params = parameters_dict["parsing_parameters"] + expression = criterion.children[0] + subs = criterion.children[1] + local_subs = [] + if subs.label == "EQUALITY": + subs = [subs] + elif subs.label == "SEPARATOR": + subs = subs.children + for sub in subs: + name = sub.children[0].content_string() + expr = parse_expression(sub.children[1].content_string(), parsing_params) + local_subs.append((name, expr)) + if label is None: + label = criterion.content_string() + + def create_expression_check(crit): + def expression_check(unused_input): + result = check_equality(crit, parameters_dict, local_substitutions=local_subs) + if result is True: + return { + label+"_TRUE": None + } + else: + return { + label+"_FALSE": None + } + return expression_check + + graph = CriteriaGraph(label) + END = CriteriaGraph.END + graph.add_node(END) + 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( + label, + label+"_TRUE", + summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), + details=expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+"is true.", + feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") + ) + graph.attach(label+"_TRUE", END.label) + graph.attach( + label, + label+"_FALSE", + summary="not "+expression.content_string(), + details=expression.content_string()+" is not true when "+", ".join([s.content_string() for s in subs])+".", + feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("FALSE") + ) + + reserved_expressions = list(parameters_dict["reserved_expressions"].items()) + response = parameters_dict["reserved_expressions"]["response"] + expression_to_vary = None + if expression.children[0].content_string().strip() == "response": + expression_to_vary = expression.children[1] + elif expression.children[1].content_string().strip() == "response": + expression_to_vary = expression.children[0] + if expression_to_vary is not None and "response" in expression_to_vary.content_string(): + expression_to_vary = None + if expression_to_vary is not None: + response_value = response.subs(local_subs) + expression_to_vary = parse_expression(expression_to_vary.content_string(), parsing_params).subs(reserved_expressions) + variation_groups = { + "ONE_ADDITION_TO_SUBTRACTION": { + "variations": one_addition_to_subtraction(expression_to_vary), + "summary": lambda expression, variations: criterion.children[0].content_string()+" if one addition is changed to a subtraction or vice versa.", + "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + }, + "ONE_EXPONENT_FLIP": { + "variations": one_exponent_flip(expression_to_vary), + "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one exponent has its sign changed.", + "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + }, + "ONE_SWAP_ADDITION_AND_MULTIPLICATION": { + "variations": one_swap_addition_and_multiplication(expression_to_vary), + "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one addition is replaced with a multiplication or vice versa.", + "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), + } + } + value = expression_to_vary.subs(local_subs).simplify() + values_and_expressions = {str(value): set([expression_to_vary])} + values_and_variations_group = {str(value): set(["UNKNOWN"])} + for (group_label, info) in variation_groups.items(): + for variation in info["variations"]: + value = variation.subs(local_subs).simplify() + values_and_expressions.update({str(value): values_and_expressions.get(str(value), set()).union(set([variation]))}) + if value != expression_to_vary.subs(local_subs): + values_and_variations_group.update({str(value): values_and_variations_group.get(str(value), set()).union(set([group_label]))}) + if len(values_and_expressions) > 1: + def identify_reason(unused_input): + reasons = { + label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) + } + return reasons + graph.attach( + label+"_FALSE", + label+"_IDENTIFY_REASON", + summary="Identify reason.", + details="Attempt to identify why the response is incorrect.", + evaluate=identify_reason + ) + graph.attach( + label+"_IDENTIFY_REASON", + label+"_UNKNOWN", + summary="Unknown reason", + details="No candidates for how the response was computed were found.", + feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") + ) + graph.attach(label+"_UNKNOWN", END.label) + + def get_candidates(unused_input): + candidates = { + label+"_RESPONSE_CANDIDATES_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group[str(response_value)] + } + return candidates + + for (group_label, group_info) in variation_groups.items(): + graph.attach( + 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( + label+"_"+group_label, + label+"_GET_CANDIDATES_"+group_label, + summary="Get candidate responses that satisfy "+expression.content_string(), + details="Get candidate responses that satisfy "+expression.content_string(), + evaluate=get_candidates + ) + + for (value, expressions) in values_and_expressions.items(): + expressions_string = ", ".join([str(e) for e in expressions]) + for group_label in values_and_variations_group[value]: + if group_label != "UNKNOWN": + group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] + if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: + graph.attach( + label+"_GET_CANDIDATES_"+group_label, + label+"_RESPONSE_CANDIDATES_"+group_label, + summary="response = "+str(value), + details="Response candidates: "+expressions_string + ) + graph.attach( + label+"_RESPONSE_CANDIDATES_"+group_label, + END.label + ) + return graph + + +def criterion_eval_node(criterion, parameters_dict, generate_feedback=True): + feedback_string_generator_inputs = {'criterion': criterion} + + def evaluation_node_internal(unused_input): + result = check_criterion(criterion, parameters_dict, generate_feedback) + label = criterion.content_string() + if result: + return { + label+"_TRUE": feedback_string_generator_inputs + } + else: + return { + label+"_FALSE": feedback_string_generator_inputs + } + label = criterion.content_string() + graph = CriteriaGraph(label) + END = CriteriaGraph.END + graph.add_node(END) + graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) + graph.attach( + label, + label+"_TRUE", + summary="True", + details=label+" is true.", + feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") + ) + graph.attach(label+"_TRUE", END.label) + graph.attach( + label, + label+"_FALSE", + summary="True", + details=label+" is false.", + feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") + ) + graph.attach(label+"_FALSE", END.label) + return graph + + +def feedback_procedure_generator(parameters_dict): + graphs = dict() + criteria = set(parameters_dict["criteria"].items()) + for (label, criterion) in criteria: + graph_templates = { + "EQUALITY": criterion_equality_node, + "WHERE": criterion_where_node, + "WRITTEN_AS": criterion_written_as_node, + } + graph_template = graph_templates.get(criterion.label, criterion_eval_node) + graph = graph_template(criterion, parameters_dict) + for evaluation in graph.evaluations.values(): + if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): + evaluation.replacement = CriteriaGraph.END + 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 + + +context = { + "expression_preview": preview_function, + "generate_criteria_parser": generate_criteria_parser, + "generate_feedback": generate_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, +} diff --git a/app/docs/dev.md b/app/docs/dev.md new file mode 100644 index 0000000..d852edd --- /dev/null +++ b/app/docs/dev.md @@ -0,0 +1,213 @@ +# CompareExpressions +This function utilises the [`SymPy`](https://docs.sympy.org/latest/index.html) to provide a maths-aware evaluation of a learner's response. + +## Architecture overview +The execution of the evaluation function follows this pattern: + +- Determine context +- Parse response and answer data +- Parse criteria +- Store input parameters, parsed responses in a key-value store that allows adding new fields, but not editing existing fields +- Execute generation feedback procedure provided by the context to generate written feedback and tags +- Serialise generated feedback and tags in a suitably formatted dictionary + +## Evaluation function + +The main evaluation function is found in `evaluation.py` as has the following signature: + +`evaluation_function(response : str, answer : str, params: dict, include_test_data=False : bool) -> dict` + +### Input + +This is the function that should be called to evaluate a response expression. +- `response` is the response expression submitted by the learner +- `answer` is a reference expression provided by the task author +- `params` is a dictionary with optional parameters, for available parameters and their intended use, see the user documentation +- `include_test_data` is a boolean that controls whether some extra data useful for testing or debugging is returned + +### Output + +The function returns result dictionary with the following fields: +- `is_correct` is a boolean value that indicates whether the response is considered correct or not +- `feedback` is a string that provides information about what the evaluation function found when evaluating the response that is intended to be shown to the learner +- `tags` is a list of strings that encode some information about what the evaluation function has found out about the response, more consistent across similar tasks than the string output in feedback + +The returned dictionary will be referred to as the `result` in this documentation. + +### Overview +The overall flow of the evaluation procedure can be described as follows: + +1. The function uses the parameters given in `params` to determine the context of the evaluation. What context means will be discussed in more detail in section TODO: Add section name here. +2. After the context is determined the response, answer and criteria (either supplied via `params` or from the context) are analysed an necessary information is stored for future use in a dictionary with frozen valuues, i.e. a dictionary where new items can be added but existing items cannot be changed. +3. The feedback generating procedure supplied by the context is used to generate feedback based on the contents of the frozen value dictionary. +4. If all criteria are found to be satisfied the response is considered correct, i.e. the `is_correct` field in the result is set to true and the feedback string and list of tags generated by the feedback generation procedure are added to their respective fields. + +**TODO** Describe what further information is supplied when `include_test_data` is set to true. + +### 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`. +- `expression_parse` function that parses expressions (i.e. the `response` and `answer` inputs) into the form used by the feedback generation procedure. +- `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. +- `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. +- `feedback_procedure_generator` function that generates a function for each criteria that can be used to evaluate if the criteria is satisfied or not. The output from this function should be a list of tags that the feedback string generator can use to produce human readable feedback. +- `feedback_string_generator` function that takes tags and outputs human readable feedback strings. +- `generate_criteria_parser` function that generates a parser that can be used to turn the criteria (given in string form) into a form that the feedback generation procedure can use to determine if they are correct or not. + +The context can also contain other fields if necessary. + +**Remark:** The current implementation uses a dictionary rather than a dedicated class for ease of iteration during the initial development phase. + +There are currently two different contexts: +- `symbolic`: Handles comparisons of various symbolic expressions. Defined in `context\symbolic.py`. +- `physical_quantity`: Handles comparisons of expressions involving units. Defined in `context\physical_quantity.py`. + +**Remark:** Handwritten expressions are sent as latex, which requires extra preprocessing before the right context can be determined in some cases. It should be considered whether a new context, perhaps called `handwritten`, should be created for this purpose. + +**TODO** Describe currently available contexts in detail + +#### `symbolic` - Comparison of symbolic expressions + +**Remark:** The `symbolic` context should probably be split into several smaller contexts, the following subdivision is suggested: +- `numerical`: Comparison of expressions that can be evaluated to numerical values (e.g. expressions that are already numerical values or expressions only containing constants). Focuses on identifying if numerical values are greater than, less than, proportional to the expected answer or similar. +- `symbolic`: Comparison of symbolic expressions that cannot be reduced to numerical values. +- `equality`: Comparison of mathematical equalities (with the extra complexities that come with equivalence of equalities compared to equality of expressions). +- `inequality`: Same as `equality` except for mathematical inequalities (which will require different choices when it comes to what can be considered equivalence). It might be appropriate to combine `equality` and `inequality` into one context (called `statements` or similar). +- `collection`: Comparison of collections (e.g. sets, lists or intervals of the number line). Likely to consist mostly of code for handling comparison of individual elements using the other contexts, and configuring what counts as equivalence between different collections. + +##### `symbolic` Criteria commands and grammar +Criteria + +The criteria commands uses the following productions +``` + START -> BOOL + BOOL -> EQUAL + BOOL -> ORDER + BOOL -> EQUAL + BOOL -> EQUAL + BOOL -> RESERVED written as OTHER + BOOL -> RESERVED written as RESERVED + BOOL -> RESERVED contains OTHER + BOOL -> RESERVED contains RESERVED + EQUAL_LIST -> EQUAL;EQUAL + EQUAL_LIST -> EQUAL_LIST;EQUAL + EQUAL -> OTHER = OTHER + EQUAL -> RESERVED = OTHER + EQUAL -> OTHER = RESERVED + EQUAL -> RESERVED = RESERVED + EQUAL -> OTHER ORDER OTHER + EQUAL -> RESERVED ORDER OTHER + EQUAL -> OTHER ORDER RESERVED + EQUAL -> RESERVED ORDER RESERVED + OTHER -> RESERVED OTHER + OTHER -> OTHER RESERVED + OTHER -> OTHER OTHER +``` +along the the following base tokens: + +- `START`: Formal token used to indicate the start of an expression (in practice: any expression that can be reduced to a single `START` is a parseable criterion). +- `END`: Formal token that indicates the end of a tokenized string. +- `NULL`: Formal token that denotes a token without meaning, should not appear when an expression is tokenized. +- `BOOL`: Expression that can be reduced to either `True` or `False`. +- `EQUAL`: Token that denotes symbolic equality between the mathematical expressions. +- `EQUALITY`: Token that denotes the equality operator `=`. +- `EQUAL_LIST`: Token that denotes a list of equalities. +- `RESERVED`: Token that denotes a formal name for a reserved name for an expression. Reserved names include `response` and `answer`. +- `ORDER`: Token that denotes an order operator. Order operators include `>`, `<`, `>=` and `<=`. +- `WHERE`: Token that denotes the separation of a criteria and a list of equalities that describe substitutions that should be done before the criteria is checked. +- `WRITTEN_AS`: Token that denotes that syntactical comparison should be done. +- `CONTAINS`: Token that denotes that a mathematical expression is dependent on a symbol or subexpression. +- `SEPARATOR`: Token that denotes which symbol is used to separate a the list of equalities used by `WHERE`. +- `OTHER`: Token that denotes any substring that will be passed on for more context specific parsing (e.g. explicit mathematical expressions for symbolic comparisons). + +##### Examples of commonly used criteria + +**TODO** Add examples + +#### `physical_quantity` - Comparison of expressions that involve units + +##### `physical_quantity` Criteria commands and grammar + +The criteria commands uses the following productions +``` + START -> BOOL + BOOL -> EQUAL + BOOL -> ORDER + BOOL -> EQUAL where EQUAL + BOOL -> EQUAL where EQUAL_LIST + BOOL -> RESERVED written as OTHER + BOOL -> RESERVED written as RESERVED + BOOL -> RESERVED contains OTHER + BOOL -> RESERVED contains RESERVED + EQUAL_LIST -> EQUAL;EQUAL + EQUAL_LIST -> EQUAL_LIST;EQUAL + EQUAL -> OTHER = OTHER + EQUAL -> RESERVED = OTHER + EQUAL -> OTHER = RESERVED + EQUAL -> RESERVED = RESERVED + EQUAL -> OTHER ORDER OTHER + EQUAL -> RESERVED ORDER OTHER + EQUAL -> OTHER ORDER RESERVED + EQUAL -> RESERVED ORDER RESERVED + OTHER -> RESERVED OTHER + OTHER -> OTHER RESERVED + OTHER -> OTHER OTHER +``` +along the the following base tokens: + +- `START`: Formal token used to indicate the start of an expression (in practice: any expression that can be reduced to a single `START` is a parseable criterion). +- `END`: Formal token that indicates the end of a tokenized string. +- `NULL`: Formal token that denotes a token without meaning, should not appear when an expression is tokenized. +- `BOOL`: Expression that can be reduced to either `True` or `False`. +- `QUANTITY`: Token that denotes a physical quantity, that can be either given as both a value and units, only value (i.e. a dimensionless quantity) or only units. +- `DIMENSION`: Token that denotes an expression only containing physical dimensions. +- `START_DELIMITER`: Token that denotes a list of equalities. +- `INPUT`: Token that denotes any substring that will be passed on for more context specific parsing (e.g. explicit mathematical expressions for symbolic comparisons). +- `matches`: Token for operator that checks in two quantities match, i.e. if they are rewritten using the same units, are their values equal (up to chosen tolerance). +- `dimension`: Token for expression only involving dimensions (i.e. no values or units). +- `=`: Token for operator that checks equality (i.e. compares if value and units are identical separately) +- `<=`: Token for operator that checks if a quantity's value is less than or equal to another quantity's value (after both quantities are rewritten on the same units) +- `>=`: Token for operator that checks if a quantity's value is greater than or equal to another quantity's value (after both quantities are rewritten on the same units) +- `<`: Token for operator that checks if a quantity's value is less than another quantity's value (after both quantities are rewritten on the same units) +- `>`: Token for operator that checks if a quantity's value is greater than another quantity's value (after both quantities are rewritten on the same units) + +##### Examples of commonly used criteria + +**TODO** Add examples + +#### Code shared between different contexts + +##### Expression parsing + +**TODO** Describe shared code for expression preprocessing and parsing + +**TODO** Describe shared code for expression parsing parameters + +##### Other shared code + +**TODO** Describe shared default parameters + +## Feedback and tag generation + +- Generate feedback procedures from criteria, each procedure return a boolean that indicates whether the corresponding criterion is satisfied or not, a string intended to be shown to the student, and a list of tags indicating what was found when checking the criteria +- For each criterion; run the corresponding procedure and store the result, the feedback string and the list of tags +- If all criteria are found to be true, then the response is considered correct + +### Tag conventions +The feedback procedures consists of a series of function calls, the specifics are determined by the particular criteria, that each return a list of strings (called tags). Each tag then indicates what further function calls must be performed to continue the evaluation, as well as what feedback string (if any) should be generated. When there are no remaining function calls the feedback procedure is completed. The tags are formatted according as *criteria*`_`*name of function call outcome*. For tags that are not connected to a specific criteria (e.g. tags that indicate an issue with expression parsing) the criteria name and underscore is omitted. + +## Returning final results +The function returns result dictionary with the following fields: +- `is_correct` is a boolean value that is set to `True` is all criteria are satisfied +- `feedback` is a string that is created by joining all strings generated by the feedback procedures with a line break between each string. +- `tags` is a list of strings that is generated by joining all lists of tags generated by feedback procedures and removing duplicates. + +# 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`. + +**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. + +# 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 diff --git a/app/docs/user.md b/app/docs/user.md new file mode 100644 index 0000000..58130af --- /dev/null +++ b/app/docs/user.md @@ -0,0 +1,421 @@ +# CompareExpressions + +This function utilises the [`SymPy`](https://docs.sympy.org/latest/index.html) to provide a maths-aware comparison of a student's response to the correct answer. This means that mathematically equivalent inputs will be marked as correct. Note that `pi` is a reserved constant and cannot be used as a symbol name. + +Note that this function is designed to handle comparisons of mathematical expressions but has some limited ability to handle comparison of equalities as well. More precisely, if the answer is of the form $f(x_1,\ldots,x_n) = g(x_1,\ldots,x_n)$ and the response is of the form $\tilde{f}(x_1,\ldots,x_n) = \tilde{g}(x_1,\ldots,x_n)$ then the function checks if $f(x_1,\ldots,x_n) - g(x_1,\ldots,x_n)$ is a multiple of $\tilde{f}(x_1,\ldots,x_n) / \tilde{g}(x_1,\ldots,x_n)$. + +## Inputs + +### Optional parameters + +There are 15 optional parameters that can be set: `atol`, `complexNumbers`, `convention`, `criteria`, `elementary_functions`, `feedback_for_incorrect_response`, `multiple_answers_criteria`, `physical_quantity`, `plus_minus`/`minus_plus`, `rtol`, `specialFunctions`, `strict_syntax`, `strictness`, `symbol_assumptions`. + +## `atol` +Sets the absolute tolerance, $e_a$, i.e. if the answer, $x$, and response, $\tilde{x}$, are numerical values then the response is considered equal to the answer if $|x-\tilde{x}| \leq e_aBy default `atol` is set to `0`, which means the comparison will be done with as high accuracy as possible. If either the answer or the response aren't numerical expressions this parameter is ignored. + +## `complexNumbers` + +If you want to use `I` for the imaginary constant, set the grading parameter `complexNumbers` to True. + +## `convention` + +Changes the implicit multiplication convention. If unset it will default to `equal_precedence`. + +If set to `implicit_higher_precedence` then implicit multiplication will have higher precedence than explicit multiplication, i.e. `1/ab` will be equal to `1/(ab)` and `1/a*b` will be equal to `(1/a)*b`. + +If set to `equal_precedence` then implicit multiplication will have the same precedence than explicit multiplication, i.e. both `1/ab` and `1/a*b` will be equal to `(1/a)*b`. + +## `criteria` + +The `criteria` parameter can be used to customize the comparison performed by the evaluation function. If unset the evaluation function will will default to checking if the answer and response are symbolically equal. + +The `criteria` parameter takes a string that defines a set of (comma separated) mathematical statements. If all statements in the list are true the response is considered correct. + +The `criteria` parameter reserves `response` and `answer` as keywords that will be replaced y the response and answer respectively when the criteria is checked. Setting `criteria` to `answer=response` is gives the same behaviour as leaving `criteria` unset. + +**Note:** Currently the `criteria` parameter is ignored if `physical_quantity` is set to true. + +**Note:** The `criteria` parameters functionality is currently under development and will rarely produce appropriate feedback and can be quite difficult to debug. + +### Available criteria + +**Note:** In the table below EXPRESSION is used to denote some mathematical expression, i.e. a string that contains mathematical symbols and operators, but no equal signs `=` or inequality signs `>`, '<'. + +| Name | Syntax | Description | Example | +|-------|:-------------------------------|:------------------------------------|:--------------------| +| EQUAL | `EXPRESSION = EXPRESSION` | Checks if the expressions are equal | `answer = response` | +| WHERE | `EXPRESSION = EXPRESSION where EXPRESSION = EXPRESSION, ... , EXPRESSION = EXPRESSION` | Checks if the equality on the left side of `where` are equal if the equalities in the comma-separated list on the right side of `where` | `answer = response` | + + +## `elementary_functions` + +When using implicit multiplication function names with multiple characters are sometimes split and not interpreted properly. Setting `elementary_functions` to true will reserve the function names listed below and prevent them from being split. If a name is said to have one or more alternatives this means that it will accept the alternative names but the reserved name is what will be shown in the preview. + +`sin`, `sinc`, `csc` (alternative `cosec`), `cos`, `sec`, `tan`, `cot` (alternative `cotan`), `asin` (alternative `arcsin`), `acsc` (alternatives `arccsc`, `arccosec`), `acos` (alternative `arccos`), `asec` (alternative `arcsec`), `atan` (alternative `arctan`), `acot` (alternatives `arccot`, `arccotan`), `atan2` (alternative `arctan2`), `sinh`, `cosh`, `tanh`, `csch` (alternative `cosech`), `sech`, `asinh` (alternative `arcsinh`), `acosh` (alternative `arccosh`), `atanh` (alternative `arctanh`), `acsch` (alternatives `arccsch`, `arcosech`), `asech` (alternative `arcsech`), `exp` (alternative `Exp`), `E` (equivalent to `exp(1)`, alternative `e`), `log`, `sqrt`, `sign`, `Abs` (alternative `abs`), `Max` (alternative `max`), `Min` (alternative `min`), `arg`, `ceiling` (alternative `ceil`), `floor` + +## `feedback_for_incorrect_response` +All feedback for all incorrect responses will be replaced with the string that this parameter is set to. + +## `multiple_answers_criteria` + +The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively. + +Answers or responses that contain $\pm$ or $\mp$ has two possible interpretations which requires further criteria for equality. The grading parameter `multiple_answers_criteria` controls this. The default setting, `all`, is that each answer must have a corresponding answer and vice versa. The setting `all_responses` check that all responses are valid answers and the setting `all_answers` checks that all answers are found among the responses. + +## `physical_quantity` + +If unset, `physical_quantity` will default to `false`. + +If `physical_quantity` is set to `true` the answer and response will interpreted as a physical quantity using units and conventions decided by the `strictness` and `units_string` parameters. + +**Remark:** Setting `physical_quantity` to `true` will also mean that comparisons will be done numerically. If neither the `atol` nor `rtol` parameters are set, the evaluation function will choose a relative error based on the number of significant digits given in the answer. + + +When `physical_quantity` the evaluation function will generate feedback based on the flowchart below. + +**TODO:** Generate new flowchart for updated physical quantity feedback generation procedure. + +## `rtol` +Sets the relative tolerance, $e_r$, i.e. if the answer, $x$, and response, $\tilde{x}$, are numerical values then the response is considered equal to the answer if $\left|\frac{x-\tilde{x}}{x}\right| \leq e_r$. By default `rtol` is set to `0`, which means the comparison will be done with as high accuracy as possible. If either the answer or the response aren't numerical expressions this parameter is ignored. + +## `strictness` + +Controls the conventions used when parsing physical quantities. + +**Remark:** If `physical_quantity` is set to `false`, this parameter will be ignored. + +There are three possible values: `strict`, `natural` and `legacy`. If `strict` is chosen then quantities will be parsed according to the conventions described in 5.1, 5.2, 5.3.2, 5.3.3 in https://www.bipm.org/documents/20126/41483022/si_brochure_8.pdf and 5.2, 5.3, 5.4.2 and 5.4.3 in https://www.bipm.org/documents/20126/41483022/SI-Brochure-9-EN.pdf. If `natural` is chosen then less restrictive conventions are used. + +**Remark:** The default setting is `natural`. + +**Remark:** The `legacy` setting should not be used and is only there to allow compatibility with content designed for use with older versions of the evaluation function. If you encounter a question using the `legacy` setting is recommended that it is changed to another setting and the answer is redefined to match the chosen conventions. + +## `units_string` + +Controls what sets of units are used. There are three values `SI`, `common` and `imperial`. + +If `SI` is chosen then only units from the tables `Base SI units` and `Derived SI units` (below) are allowed (in combinations with prefixes). If `common` is chosen then all the units allowed by `SI` as well as those listed in the tables for `Common non-SI units`. If `imperial` is chosen the base SI units and the units listed in the `Imperial units` table are allowed. + +**Remark:** The different settings can also be combined, e.g. `SI common imperial` will allow all units. + +The default setting is to allow all units, i.e. `units_string` is set to `SI common imperial`. + +### Notation and definition of units + +#### Table: Base SI units + +SI base units based on Table 2 in https://www.bipm.org/documents/20126/41483022/SI-Brochure-9-EN.pdf + +Note that gram is used as a base unit instead of kilogram. + +| SI base unit | Symbol | Dimension name | +|--------------|:-------|:--------------------| +| metre | m | length | +| gram | g | mass | +| second | s | time | +| ampere | A | electriccurrent | +| kelvin | k | temperature | +| mole | mol | amountofsubstance | +| candela | cd | luminousintensity | + +#### Table: SI prefixes + +SI prefixes based on Table 7 in https://www.bipm.org/documents/20126/41483022/SI-Brochure-9-EN.pdf + +| SI Prefix | Symbol | Factor | | SI Prefix | Symbol | Factor | +|-----------|:-------|:-----------|-|-----------|:-------|:-----------| +| yotta | Y | $10^{24}$ | | deci | d | $10^{-1}$ | +| zetta | Z | $10^{21}$ | | centi | c | $10^{-2}$ | +| exa | E | $10^{18}$ | | milli | m | $10^{-3}$ | +| peta | P | $10^{15}$ | | micro | mu | $10^{-6}$ | +| tera | T | $10^{12}$ | | nano | n | $10^{-9}$ | +| giga | G | $10^{9}$ | | pico | p | $10^{-12}$ | +| mega | M | $10^{6}$ | | femto | f | $10^{-15}$ | +| kilo | k | $10^{3}$ | | atto | a | $10^{-18}$ | +| hecto | h | $10^{2}$ | | zepto | z | $10^{-21}$ | +| deka | da | $10^{1}$ | | yocto | y | $10^{-24}$ | + +#### Table: Derived SI units + +Derived SI based on Table 4 in https://www.bipm.org/documents/20126/41483022/SI-Brochure-9-EN.pdf + +Note that the function treats radians and steradians as dimensionless values. + +| Unit name | Symbol | Expressed in base SI units | +|-----------|:-------|:---------------------------------------------------------------------------------| +| radian | r | $(2\pi)^{-1}$ | +| steradian | sr | $(4\pi)^{-1}$ | +| hertz | Hz | $\mathrm{second}^{-1}$ | +| newton | N | $\mathrm{metre}~\mathrm{kilogram}~\mathrm{second}^{-2}$ | +| pascal | Pa | $\mathrm{metre}^{-1}~\mathrm{kilogram}~\mathrm{second}^{-2}$ | +| joule | J | $\mathrm{metre}^2~\mathrm{kilogram~second}^{-2}$ | +| watt | W | $\mathrm{metre}^2~\mathrm{kilogram~second}^{-3}$ | +| coulomb | C | $\mathrm{second~ampere}$ | +| volt | V | $\mathrm{metre}^2~\mathrm{kilogram second}^{-3}~\mathrm{ampere}^{-1}$ | +| farad | F | $\mathrm{metre}^{-2}~\mathrm{kilogram}^{-1}~\mathrm{second}^4~\mathrm{ampere}^2$ | +| ohm | O | $\mathrm{metre}^2~\mathrm{kilogram second}^{-3}~\mathrm{ampere}^{-2}$ | +| siemens | S | $\mathrm{metre}^{-2}~\mathrm{kilogram}^{-1}~\mathrm{second}^3~\mathrm{ampere}^2$ | +| weber | Wb | $\mathrm{metre}^2~\mathrm{kilogram~second}^{-2}~\mathrm{ampere}^{-1}$ | +| tesla | T | $\mathrm{kilogram~second}^{-2} \mathrm{ampere}^{-1}$ | +| henry | H | $\mathrm{metre}^2~\mathrm{kilogram~second}^{-2}~\mathrm{ampere}^{-2}$ | +| lumen | lm | $\mathrm{candela}$ | +| lux | lx | $\mathrm{metre}^{-2}~\mathrm{candela}$ | +| becquerel | Bq | $\mathrm{second}^{-1}$ | +| gray | Gy | $\mathrm{metre}^2~\mathrm{second}^{-2}$ | +| sievert | Sv | $\mathrm{metre}^2~\mathrm{second}^{-2}$ | +| katal | kat | $\mathrm{mole~second}^{-1}$ | + +#### Table: Common non-SI units + +Commonly used non-SI units based on Table 8 in https://www.bipm.org/documents/20126/41483022/SI-Brochure-9-EN.pdf and Tables 7 and 8 in https://www.bipm.org/documents/20126/41483022/si_brochure_8.pdf +Note that the function treats angles, neper and bel as dimensionless values. + +Note that only the first table in this section has short form symbols defined, the second table does not, this is done to minimize ambiguities when writing units. + +| Unit name | Symbol | Expressed in SI units | +|-------------------|:-------|:-------------------------------------------| +| minute | min | $60~\mathrm{second}$ | +| hour | h | $3600~\mathrm{second}$ | +| degree | deg | $\frac{1}{360}$ | +| liter | l | $10^{-3}~\mathrm{metre}^3$ | +| metric_ton | t | $10^3~\mathrm{kilogram}$ | +| neper | Np | $1$ | +| bel | B | $\frac{1}{2}~\ln(10)$ | +| electronvolt | eV | $1.60218 \cdot 10^{-19}~\mathrm{joule}$ | +| atomic_mass_unit | u | $1.66054 \cdot 10^{-27}~\mathrm{kilogram}$ | +| angstrom | å | $10^{-10}~\mathrm{metre}$ | + +| Unit name | Expressed in SI units | +|------------------|:-----------------------------------------------------| +| day | $86400~\mathrm{second}$ | +| angleminute | $\frac{\pi}{10800}$ | +| anglesecond | $\frac{\pi}{648000}$ | +| astronomicalunit | $149597870700~\mathrm{metre}$ | +| nauticalmile | $1852~\mathrm{metre}$ | +| knot | $\frac{1852}{3600}~\mathrm{metre~second}^{-1}$ | +| are | $10^2~\mathrm{metre}^2$ | +| hectare | $10^4~\mathrm{metre}^2$ | +| bar | $10^5~\mathrm{pascal}$ | +| barn | $10^{-28}~\mathrm{metre}$ | +| curie | $3.7 \cdot 10^{10}~\mathrm{becquerel} | +| roentgen | $2.58 \cdot 10^{-4}~\mathrm{kelvin~(kilogram)}^{-1}$ | +| rad | $10^{-2}~\mathrm{gray}$ | +| rem | $10^{-2}~\mathrm{sievert}$ | + +#### Table: Imperial units + +Commonly imperial units taken from https://en.wikipedia.org/wiki/Imperial_units + +| Unit name | Symbol | Expressed in SI units | +|-------------------|:-------|:----------------------------------------------| +| inch | in | $0.0254~\mathrm{metre}$ | +| foot | ft | $0.3048~\mathrm{metre}$ | +| yard | yd | $0.9144~\mathrm{metre}$ | +| mile | mi | $1609.344~\mathrm{metre}$ | +| fluid ounce | fl oz | $28.4130625~\mathrm{millilitre}$ | +| gill | gi | $142.0653125~\mathrm{millilitre}$ | +| pint | pt | $568.26125~\mathrm{millilitre}$ | +| quart | qt | $1.1365225~\mathrm{litre}$ | +| gallon | gal | $4546.09~\mathrm{litre}$ | +| ounce | oz | $28.349523125~\mathrm{gram}$ | +| pound | lb | $0.45359237~\mathrm{kilogram}$ | +| stone | st | $6.35029318~\mathrm{kilogram}$ | + +## `plus_minus` and `minus_plus` + +The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively. + +To use other symbols for $\pm$ and $\mp$ set the grading parameters `plus_minus` and `minus_plus` to the desired symbol. **Remark:** symbol replacement is brittle and can have unintended consequences. + +## `specialFunctions` + +If you want to use the special functions `beta` (Euler Beta function), `gamma` (Gamma function) and `zeta` (Riemann Zeta function), set the grading parameter `specialFunctions` to True. + +## `strict_syntax` + +If `strict_syntax` is set to true then the answer and response must have `*` or `/` between each part of the expressions and exponentiation must be done using `**`, e.g. `10*x*y/z**2` is accepted but `10xy/z^2` is not. + +If `strict_syntax` is set to false, then `*` can be omitted and `^` used instead of `**`. In this case it is also recommended to list any multicharacter symbols expected to appear in the response as input symbols. + +By default `strict_syntax` is set to true. + +## `symbol_assumptions` + +This input parameter allows the author to set an extra assumption each symbol. Each assumption should be written on the form `('symbol','assumption name')` and all pairs concatenated into a single string. + +The possible assumptions are: `constant`, `function` as well as those listed here: +[`SymPy Assumption Predicates`](https://docs.sympy.org/latest/guides/assumptions.html#predicates) + +**Note:** Writing a symbol which denotes a function without its arguments, e.g. `T` instead of `T(x,t)`, is prone to cause errors. + +## Examples + +Implemented versions of these examples can be found in the module 'Examples: Evaluation Functions'. + +### Setting input symbols to be assumed positive to avoid issues with fractional powers + +In general $\frac{\sqrt{a}}{\sqrt{b}} \neq \sqrt{\frac{a}{b}}$ but if $a > 0$ and $b > 0$ then $\frac{\sqrt{a}}{\sqrt{b}} = \sqrt{\frac{a}{b}}$. The same is true for other fractional powers. + +So if expressions like these are expected in the answer and/or response then it is a good idea to use the `symbol_assumptions` parameter to note that $a > 0$ and $b > 0$. This can be done by setting `symbol_assumptions` to `('a','positive') ('b','positive')`. + +The example given in the example problem set uses two EXPRESSION response areas. Both response areas uses `compareExpression` with answer `sqrt(a/b)`, `strict_syntax` set to false, `elementary_functions` set to true. One response area leaves `symbol_assumptions` unset and the other sets the parameter as described in the previous paragraph. Some examples of expressions that are accepted as correct when positivity is assumed: +`sqrt(a)/sqrt(b)`, `(a/b)**(1/2)`, `a**(1/2)/b**(1/2)`, `(a/b)^(0.5)`, `a^(0.5)/b^(0.5)` + +### Using plus/minus symbols + +The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively. To use other symbols for $\pm$ and $\mp$ set the grading parameters `plus_minus` and `minus_plus` to the desired symbol. **Remark:** symbol replacement is brittle and can have unintended consequences. + +It is considered good practice to make sure that the appropriate notation for $\pm$ and $\mp$ are added and displayed as input symbols in order to minimize confusion. + +The example given in the example problem set uses an EXPRESSION response area that uses `compareExpression` with answer `plus_minus x**2 + minus_plus y**2`, `strict_syntax` set to false and `elementary_function` set to true. Some examples of expressions that are accepted as correct: +`plus_minus x**2 + minus_plus y**2`, `- minus_plus x**2 + minus_plus y**2`, `- minus_plus x^2 minus_plus y^2`, `- minus_plus x^2 - plus_minus y^2` + +### Equalities in the answer and response + +There is (limited) support for using equalities in the response and answer. More precisely, if the answer is of the form $f(x_1,\ldots,x_n) = g(x_1,\ldots,x_n)$ and the response is of the form $\tilde{f}(x_1,\ldots,x_n) = \tilde{g}(x_1,\ldots,x_n)$ then the function checks if $f(x_1,\ldots,x_n) - g(x_1,\ldots,x_n)$ is a multiple of $\tilde{f}(x_1,\ldots,x_n) / \tilde{g}(x_1,\ldots,x_n)$. + +The example given in the example problem set uses an EXPRESSION response area that uses `compareExpression` with answer `x**2-5*y**2-7=0`. Some examples of expressions that are accepted as correct: +`x**2-5*y**2-7=0`, `x^2 = 5y^2+7`, `2x^2 = 10y^2+14` + +### Checking the value of an expression or a physical quantity + +If the parameter `physical_quantity` is set to true, the evaluation function can handle expressions that describe physical quantities. Which units are permitted and how they should be written depends on the `units_string` and `strictness` parameters respectively. + +There are three examples in the example problem set. Each examples uses an EXPRESSION response area that uses `compareExpression` with answer `strict_syntax` set to false and `physical_quantity` set to true. + +#### Example (a) + +Here the answer is `2.00 km/h`. The parameters `strictness` and `units_string` are left unset which is equivalent to setting `strictness` to `natural`, and `units_string` to `SI common imperial`. Thus this response area accepts a wide range of responses, e.g. `2.00 kilometre/hour`, `2 km/h`, `2000 meter/hour`, `2 metre/millihour` + +#### Example (b) + +Here the answer is `2.00 km/h`. To restrict the answers to SI units `strictness` is set to `strict` and `units_string` is set to `SI`. Some examples of accepted responses are: `0.556 metre/second`, `5.56 dm/s`, `55.6 centimetre second^(-1)` + +#### Example (c) + +Here the answer is `2.00 km/h`. To restrict the answers to imperial units `strictness` is set to `strict` and `units_string` is set to `imperial common`. Accepted response: `1.24 mile/hour` + +### Changing convention for precedence for implicit and explicit multiplication + +You can change what convention is used for precedence for explicit and implicit multiplication by setting the `convention` parameter. + +There are two supported conventions: + +`equal_precedence` Implicit multiplication have the same precedence as explicit multiplication, i.e. both `1/ab` and `1/a*b` will be equal to `(1/a)*b`. + +`implicit_higher_precedence` Implicit multiplication have higher precedence than explicit multiplication, i.e. `1/ab` will be equal to `1/(ab)` and `1/a*b` will be equal to `(1/a)*b`. + +In the examples set there is one response area for each of the two conventions, both with answer `1/ab`. Try `1/ab`, `(1/a)*b` and `1/(a*b)` in both response areas and note the differences in behaviour. + +### Setting absolute or relative tolerances for numerical comparison + +`compareExpressions` can be used both for symbolic and numerical comparisons. The default is symbolic comparisons. + +The evaluation function can be configured to do numerical comparisons in the following ways: + +- Setting the absolute error tolerance: This is done by setting the parameter `atol`. When `atol` is set to any positive value, then `response` will be considered correct if $|$`answer`$-$`response`$| < $`atol`. + +- Setting the relative error tolerance: This is done by setting the parameter `rtol`. When `rtol` is set to any positive value, then `response` will be considered correct if $|$`answer`$-$`response`$|/|$`answer`$| < $`rtol`. + +**Note:** If the response area is used to compare physical quantities (i.e. the parameter `physical_quantity` is set to `true`) comparisons will be numerical be default (since unit conversion has finite precision). + +#### Setting the absolute tolerance + +In the following response area the absolute tolerance (`atol`) has been set to $5$. There are three correct answers: $\sqrt{47}+\pi$ (approx. 9.9997), $\frac{13}{3}^{\pi}$ (approx. 99.228) and $9^{e+\ln(1.5305)}$ (approx. 1000.05). Any answer within the set absolute tolerance (i.e. $5<$`response`$<15$, $95<$`response`$<105$ or $995<$`response`$<1005$) of either answer will be considered correct. + +#### Setting the relative tolerance + +In the following response area the absolute tolerance (`atol`) has been set to $0.5$. There are three correct answers: $\sqrt{47}+\pi$ (approx. 9.9997), $\frac{13}{3}^{\pi}$ (approx. 99.228) and $9^{e+\ln(1.5305)}$ (approx. 1000.05). Any answer within the set absolute tolerance (i.e. $5<$`response`$<15$, $95<$`response`$<105$ or $995<$`response`$<1005$) of either answer will be considered correct. + +### Using complex numbers + +If the parameter `complexNumbers` is set to true then `I` will be interpreted as the imaginary constant $i$. + +In the example set there is a response area with `complexNumbers` set to `true` and answer `2+I`. An input symbols has also been added so that `I` can be replaced with `i` or `j`. + +Any response that is mathematically equivalent to $2+i$ will be accepted, e.g. `2+I`, `2+(-1)^(1/2)`, `conjugate(2-I)`, `2sqrt(2)e^(I*pi/4)+e^(I*3*pi/2)` or `re(2-I)-im(2-I)*I`. + +**Note:** If the particular way that the answer is written matter, e.g. only answers on Cartesian form should be accepted, then that requires further configuration, see the example *Syntactical comparison*. + +### Using `constant` and `function` assumptions + +Examples of how to use the `constant` and `function` assumptions for symbols. + +#### Comparing equalities involving partial derivatives + +The response should be some expression that is equivalent to this equation (i.e. `answer`): + +$$\frac{\partial^2 T}{\partial x^2}+\frac{\dot{q}}{k}=\frac{1}{\alpha}\frac{\partial T}{\partial t}$$ + +Here is an example of another valid response: + +$$\alpha k \frac{\partial^2 T}{\partial x^2}+ \alpha \dot{q} = k \frac{\partial T}{\partial t}$$ + +In general, if both the response and the answer are equalities, i.e. the response is $a=b$, and the answer is $c=d$, `compareExpressions` compares the answer and respose by checking if $\dfrac{a-b}{c-d}$ is a constant, i.e. it is assumed that $a-b$ and $c-d$ are not simplifyable to zero without assuming $a-b=c-d=0$. + +By default `compareExpressions` assumes that symbols are independent of each other, a consequence of this is that derivatives will become zero, e.g. $\dfrac{\mathrm{d}T}{\mathrm{d}t} = 0$. This can be prevented by assuming that some symbols are functions (a symbol assumed to be a function is assumed to depend on all other symbols). In this example we want to take derivatives of $T$ and $q$ so we add `('T','function') ('q','function')` to the `symbol_assumptions` parameter. + +Taking the ratio of the given answer and the example response gives: +$$ \frac{\frac{\partial^2 T}{\partial x^2}+\frac{\dot{q}}{k} - \frac{1}{\alpha}\frac{\partial T}{\partial t}}{\alpha k \frac{\partial^2 T}{\partial x^2}+ \alpha \dot{q} - k \frac{\partial T}{\partial t}} = \alpha k $$ + +By default $\alpha$ and $k$ are assumed to be variables so the ratio is not seen as a constant. This can be fixed by adding `('alpha','constant') ('k','constant')` to the `symbol_asssumptions` parameter. + +The make it simpler and more intuitive to write valid responses we add the following input symbols: + +| Symbol | Code | Alternatives | +| --------------------------------------- | ----------------------- | --------------------------- | +| $\dot{q}$ | `Derivative(q(x,t),t)` | `q_{dot}, q_dot` | +| $\dfrac{\mathrm{d}T}{\mathrm{d}t}$ | `Derivative(T(x,t),t)` | `dT/dt` | +| $\dfrac{\mathrm{d}T}{\mathrm{d}x}$ | `Derivative(T(x,t),x)` | `dT/dx` | +| $\dfrac{\mathrm{d}^2 T}{\mathrm{d}x^2}$ | `Derivative(T(x,t),x,x)`| `(d^2T)/(dx^2), d^2T/dx^2` | + +Suggestions of correct responses to try: + +`Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)` + +`alpha*k*(d^2T)/(dx^2) = k*(dT/dt) - alpha*q_dot` + +`d^2T/dx^2 + q_dot/k = 1/alpha*(dT/dt)` + +`(d^2T)/(dx^2) + q_dot/k = 1/alpha*(dT/dt)` + +A simple example of an incorrect expression: + +`k*alpha*(d^2T)/(dx^2) = k*(dT/dt) + alpha*q_dot` + +Note that omitting the arguments of functions (when not using an alias) can cause errors. + +Compare what happens with response + +`Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)` + +and + +`Derivative(T,x,x) + Derivative(q,t)/k = 1/alpha*Derivative(T,t)` + +### Syntactical comparison + +Typically `compareExpressions` only checks if the response is mathematically equivalent to the answer. If we want to require that the answer is written in a certain way, e.g. Cartesian form vs. exponential form of a complex number or standard form vs factorized form of a polynomial, further comparisons need to be done. There are some built in standard forms that can be detected, as well as a method that tries to match the way that the response is written in a limited fashion. Either method can be activated either by setting the flag `syntactical_comparison` to `true`, or by using the criteria `response written as answer`. + +#### Standard forms for complex numbers + +For complex numbers there are two known forms, Cartesian form, $a+bi$, and exponential form, $ae^{bi}$. + +For either form the pattern detection only works if $a$ and $b$ are written as numbers on decimal form, i.e. no fractions or other mathematical expressions. + +The example set has two response areas, one with an answer written in Cartesian form ($2+2i$) and one with the answer written in exponential form ($2e^{2i}$). For both response areas the parameter `complexNumbers` is set to `true` (so that `I` will be treated as the imaginary constant) and `syntactical_comparison` is set to `true` (to activate the syntactical comparison). There is also an input symbol that makes `I`, `i` and `j` all be interpreted as the imaginary constant $i$. The evaluation function automatically detects the form of the answer and uses it as the basis of the comparison. + +#### Arbitrary syntactical comparison by comparing the form of the response to the form of the answer + +If the answer is not written in a known form the evaluation function assumes that any response that we can get by taking the answer and replacing any numbers in it with other numbers is considered to be written in the same form. For example, if the answer is `(x-4)^2+5` then any expression written as `(x-A)^B-C` where `A`, `B` and `C` are replaced with non-negative numbers will be considered to be written in the correct form, see the table below for some examples. + +| Response | Correct | Mathematically equivalent | Syntactically equivalent | +| -------------- | ----------| ------------------------- | ------------------------- | +| `(x-4)^2+5` | Correct | True | True | +| `5+(x-4)^2` | Incorrect | True | False | +| `(x-4)^2-(-5)` | Incorrect | True | False | +| `(x+(-4))^2+5` | Incorrect | True | False | +| `x^2-8x+11` | Incorrect | True | False | +| `(x-2)^3+4` | Incorrect | False | True | + +**Note:** This type of comparison is quite limited in many cases and may need to be augmented with extra feedback cases or customised feedback for incorrect responses that clarifies the specifics of the expected form. diff --git a/app/evaluation.py b/app/evaluation.py new file mode 100644 index 0000000..deb0269 --- /dev/null +++ b/app/evaluation.py @@ -0,0 +1,323 @@ +from copy import deepcopy + +from .utility.evaluation_result_utilities import EvaluationResult +from .utility.preview_utilities import parse_latex +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 collections.abc import Mapping + +messages = { + "RESERVED_EXPRESSION_MISSING": lambda label: f"Reserved expression `{label}` is not defined." +} + + +class FrozenValuesDictionary(dict): + """ + A dictionary where new key:value pairs can be added, + but changing the value for an existing key raises + a TypeError + """ + def __init__(self, other=None, **kwargs): + super().__init__() + self.update(other, **kwargs) + + def __setitem__(self, key, value): + if key in self: + msg = 'key {!r} already exists with value {!r}' + raise TypeError(msg.format(key, self[key])) + super().__setitem__(key, value) + + def update(self, other=None, **kwargs): + if other is not None: + for k, v in other.items() if isinstance(other, Mapping) else other: + self[k] = v + for k, v in kwargs.items(): + self[k] = v + + +def determine_context(parameters): + if parameters.get("physical_quantity", False) is True: + context = deepcopy(quantity_context) + else: + context = deepcopy(symbolic_context) + + input_symbols_reserved_codes = list(parameters.get("symbols", dict())) + input_symbols_reserved_aliases = [] + + for input_symbol in parameters.get("symbols", dict()).values(): + input_symbols_reserved_aliases += [alias for alias in input_symbol.get("aliases", []) if len(alias.strip()) > 0] + + # This code is to ensure compatibility with legacy system for defining input symbols + for input_symbol in parameters.get("input_symbols", []): + if len(input_symbol[0].strip()) > 0: + input_symbols_reserved_codes.append(input_symbol[0]) + input_symbols_reserved_aliases += [ip for ip in input_symbol[1] if len(ip.strip()) > 0] + + reserved_keywords_codes = {"where", "written as"} + 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())) + + reserved_keywords_codes_collisions = [] + for keyword in reserved_keywords_codes: + if keyword in input_symbols_reserved_codes: + reserved_keywords_codes_collisions.append(keyword) + if len(reserved_keywords_codes_collisions) > 0: + if len(reserved_keywords_codes_collisions) == 1: + raise Exception("`"+"`, `".join(reserved_keywords_codes_collisions)+"` is a reserved keyword and cannot be used as an input symbol code.") + else: + raise Exception("`"+"`, `".join(reserved_keywords_codes_collisions)+"` are reserved keywords and cannot be used as input symbol codes.") + reserved_keywords_aliases_collisions = [] + for keyword in reserved_keywords_aliases: + if keyword in input_symbols_reserved_aliases: + print("Collision found") + reserved_keywords_aliases_collisions.append(keyword) + if len(reserved_keywords_aliases_collisions) > 0: + if len(reserved_keywords_aliases_collisions) == 1: + raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` is a reserved keyword and cannot be used as an input symbol alternative.") + else: + raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` are reserved keywords and cannot be used as input symbol alternatives.") + + reserved_keywords = reserved_keywords_codes + context.update({"reserved_keywords": list(reserved_keywords)}) + return context + + +def parse_reserved_expressions(reserved_expressions, parameters, result): + """ + Input: + reserved_expressions: dictionary with the following format + { + "learner": + : , + ... + "task": { + : , + ... + } + } + parameters: dict that contains evaluation function configuration parameters + result: the EvaluationResult object that will hold feedback responses + """ + parse = parameters["context"]["expression_parse"] + preprocess = parameters["context"]["expression_preprocess"] + parsing_parameters = deepcopy(parameters["parsing_parameters"]) + symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] + reserved_expressions_dict = FrozenValuesDictionary() + success = True + for key in reserved_expressions.keys(): + 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: + if key == "learner": + result.add_feedback(preprocess_feedback) + preprocess_success = True # Preprocess can only create warnings for responses in this case + else: + raise Exception(preprocess_feedback[1], preprocess_feedback[0]) + reserved_expressions[key][label] = expr + if not isinstance(expr, str): + raise Exception(f"Reserved expression {label} must be given as a string.") + if len(expr.strip()) == 0: + if key == "learner": + result.add_feedback( + ( + f"RESERVED_EXPRESSION_MISSING_{label}", + messages["RESERVED_EXPRESSION_MISSING"](label) + ) + ) + else: + raise Exception(messages["RESERVED_EXPRESSION_MISSING"](label), f"RESERVED_EXPRESSION_MISSING_{label}") + success = False + else: + try: + expr_parsed = parse(label, expr, parsing_parameters, result) + except Exception as e: + result.is_correct = False + success = False + if key == "learner": + result.add_feedback( + ( + f"PARSE_ERROR_{label}", + symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) + ) + ) + else: + raise Exception(symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})) from e + reserved_expressions_dict[key].update({label: expr_parsed}) + return success, reserved_expressions_dict + + +def get_criteria_string(parameters): + criteria = parameters.get("criteria", None) + if criteria is None: + criteria = ",".join(parameters["context"]["default_criteria"]) + if (parameters.get("syntactical_comparison", False) is True) and ("responsewrittenasanswer" not in "".join(criteria.split())): + criteria = criteria+", response written as answer" + return criteria + + +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]) + criteria_string_list = [] + delims = [ + ("(", ")"), + ("[", "]"), + ("{", "}"), + ] + depth = {delim: 0 for delim in delims} + delim_key = {delim[0]: delim for delim in delims} + delim_key.update({delim[1]: delim for delim in delims}) + criterion_start = 0 + for n, c in enumerate(criteria_string): + if c in [delim[0] for delim in delims]: + depth[delim_key[c]] += 1 + if c in [delim[1] for delim in delims]: + depth[delim_key[c]] -= 1 + if c == "," and all([d == 0 for d in depth.values()]): + criteria_string_list.append(criteria_string[criterion_start:n].strip()) + criterion_start = n+1 + criteria_string_list.append(criteria_string[criterion_start:].strip()) + criteria_parsed = FrozenValuesDictionary() + for criterion in criteria_string_list: + try: + criterion_tokens = criteria_parser.scan(criterion) + criterion_parsed = criteria_parser.parse(criterion_tokens)[0] + criteria_parsed.update({criterion_parsed.content_string(): criterion_parsed}) + except Exception as e: + raise Exception("Cannot parse criteria: `"+criterion+"`.") from e + return criteria_parsed + + +def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): + # Generate feedback from criteria graphs + evaluation_result = evaluation_parameters["evaluation_result"] + response = evaluation_parameters["reserved_expressions"]["response"] + criteria_feedback = set() + is_correct = True + for (criterion_identifier, graph) in criteria_graphs.items(): + # TODO: Find better way to identify main criteria for criteria graph + main_criteria = criterion_identifier+"_TRUE" + criteria_feedback = graph.generate_feedback(response, main_criteria) + + # TODO: Implement way to define completeness of task other than "all main criteria satisfied" + is_correct = is_correct and main_criteria in criteria_feedback + evaluation_result.add_criteria_graph(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) + evaluation_result.is_correct = is_correct + return + + +def evaluation_function(response, answer, params, include_test_data=False) -> dict: + """ + Function that allows for various types of comparison of various kinds of expressions. + Supported input parameters: + strict_SI_syntax: + - if set to True, use basic dimensional analysis functionality. + """ + + evaluation_result = EvaluationResult() + evaluation_result.is_correct = False + + symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] + + parameters = deepcopy(params) + + # CONSIDER: Can this be moved into the preprocessing procedures in a consistent way? + # Can it be turned into its own context? Or moved into the determine_context procedure? + # What solution will be most consistently reusable? + if parameters.get("is_latex", False): + response = parse_latex(response, parameters.get("symbols", {}), False) + + reserved_expressions_strings = { + "learner": { + "response": response + }, + "task": { + "answer": answer + } + } + parameters.update({"reserved_expressions_strings": reserved_expressions_strings}) + context = determine_context(parameters) + default_parameters = context["default_parameters"] + for (key, value) in default_parameters.items(): + if key not in parameters.keys(): + parameters.update({key: value}) + if "criteria" not in parameters.keys(): + parameters.update({"criteria": ",".join(context["default_criteria"])}) + try: + preview = context["expression_preview"](response, deepcopy(parameters))["preview"] + except Exception: + evaluation_result.latex = response + evaluation_result.simplified = response + else: + evaluation_result.latex = preview["latex"] + evaluation_result.simplified = preview["sympy"] + parameters.update( + { + "context": context, + "parsing_parameters": context["parsing_parameters_generator"](parameters), + } + ) + + # FIXME: Move this into expression_utilities + if params.get("strict_syntax", True): + if "^" in response: + 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()))) + + reserved_expressions_success, reserved_expressions = parse_reserved_expressions(reserved_expressions_strings, parameters, evaluation_result) + if reserved_expressions_success is False: + return evaluation_result.serialise(include_test_data) + reserved_expressions_parsed = {**reserved_expressions["learner"], **reserved_expressions["task"]} + parameters.update({"reserved_keywords": parameters["context"]["reserved_keywords"]+list(reserved_expressions_parsed.keys())}) + + criteria_parser = context["generate_criteria_parser"](reserved_expressions) + criteria = create_criteria_dict(criteria_parser, parameters) + + parsing_parameters = parameters["context"]["parsing_parameters_generator"](parameters, unsplittable_symbols=list(reserved_expressions_parsed.keys())) + + evaluation_parameters = FrozenValuesDictionary( + { + "reserved_expressions_strings": reserved_expressions_strings, + "reserved_expressions": reserved_expressions_parsed, + "criteria": criteria, + "disabled_evaluation_nodes": parameters.get("disabled_evaluation_nodes", set()), + "evaluation_result": evaluation_result, + "parsing_parameters": parsing_parameters, + "evaluation_result": evaluation_result, + "syntactical_comparison": parameters.get("syntactical_comparison", False), + "multiple_answers_criteria": parameters.get("multiple_answers_criteria", "all"), + "numerical": parameters.get("numerical", False), + "atol": parameters.get("atol", 0), + "rtol": parameters.get("rtol", 0), + } + ) + + # Performs evaluation of response + feedback_procedures = parameters["context"]["feedback_procedure_generator"](evaluation_parameters) + generate_feedback(criteria, feedback_procedures, evaluation_parameters) + + result = evaluation_result.serialise(include_test_data) + + if parameters.get("feedback_for_incorrect_response", None) is not None: + result["feedback"] = parameters["feedback_for_incorrect_response"] + + return result diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py new file mode 100644 index 0000000..04a470f --- /dev/null +++ b/app/evaluation_tests.py @@ -0,0 +1,84 @@ +import pytest +import os + +from .evaluation import evaluation_function + + +class TestEvaluationFunction(): + """ + TestCase Class used to test the algorithm. + --- + Tests are used here to check that the algorithm written + is working as it should. + + These tests are organised in classes to ensure that the same + calling conventions can be used for tests using unittest and + tests using pytest. + + Read the docs on how to use unittest and pytest here: + https://docs.python.org/3/library/unittest.html + https://docs.pytest.org/en/7.2.x/ + + Use evaluation_function() to call the evaluation function. + """ + + # Import tests that makes sure that mathematical expression comparison works as expected + from .tests.symbolic_evaluation_tests import TestEvaluationFunction as TestSymbolicComparison + + # Import tests that makes sure that physical quantities are handled as expected + from .tests.physical_quantity_evaluation_tests import TestEvaluationFunction as TestQuantities + + # Import tests that corresponds to examples in documentation and examples module + from .tests.example_tests import TestEvaluationFunction as TestExamples + + def test_eval_function_can_handle_latex_input(self): + response = r"\sin x + x^{7}" + answer = "sin(x)+x**7" + params = { + "strict_syntax": False, + "elementary_functions": True, + "is_latex": True + } + result = evaluation_function(response, answer, params) + assert result["is_correct"] is True + + def test_eval_function_preserves_order_in_latex_input(self): + response = r"c + a + b" + answer = "c + a + b" + params = { + "strict_syntax": False, + "elementary_functions": True, + "is_latex": True + } + result = evaluation_function(response, answer, params) + assert result["is_correct"] is True + + def test_AERO40007_1_6_instance_2024_25(self): + params = { + "strict_syntax": False, + "elementary_functions": True, + "rtol": 0.01, + } + response = "231*16.4/1000*14=4" + answer = "53" + result = evaluation_function(response, answer, params) + assert result["is_correct"] is False + assert "response = answer_EQUALITY_NOT_EXPRESSION" in result["tags"] + + def test_CHEM40002_1_5_instance_2024_25(self): + params = { + "strict_syntax": False, + "elementary_functions": True, + "complexNumbers": True, + "symbols": { + "I": {"aliases": ["i"], "latex": r"I"}, + }, + } + response = "6 exp(5pi/6*I)" + answer = "6(cos(5pi/6)+isin(5pi/6))" + result = evaluation_function(response, answer, params) + assert result["is_correct"] is True + + +if __name__ == "__main__": + pytest.main(['-k not slow', '--tb=line', '--durations=10', os.path.abspath(__file__)]) diff --git a/evaluation_function/feedback/__init__.py b/app/feedback/__init__.py similarity index 100% rename from evaluation_function/feedback/__init__.py rename to app/feedback/__init__.py diff --git a/app/feedback/physical_quantity.py b/app/feedback/physical_quantity.py new file mode 100644 index 0000000..f4320f7 --- /dev/null +++ b/app/feedback/physical_quantity.py @@ -0,0 +1,22 @@ +feedback_string_generators = dict() +feedback_string_generators["INTERNAL"] = lambda tag: lambda inputs: { + "REVERTED_UNIT": f"Possible ambiguity: `{inputs.get('marked', '')}` was not interpreted as a unit in
`{inputs['before']}``{inputs['marked']}``{inputs['after']}`", +}[tag] +feedback_string_generators["MATCHES"] = lambda tag: lambda inputs: { + "QUANTITY_MATCH": f"{inputs.get('lhs', '')} matches {inputs.get('rhs', '')}.", + "QUANTITY_MISMATCH": f"{inputs.get('lhs', '')} does not match {inputs.get('rhs', '')}.", + "MISSING_VALUE": "The response is missing a value.", + "UNEXPECTED_VALUE": "The response is expected only have unit(s), no value.", + "MISSING_UNIT": "The response is missing unit(s).", + "UNEXPECTED_UNIT": "The response is expected to be a value without unit(s).", + "DIMENSION_MATCH": f" ${inputs.get('lhs', '')}$ has the expected dimensions", + "DIMENSION_MISMATCH": f"${inputs.get('lhs', '')}$ does not have the expected dimensions", + "UNIT_COMPARISON_IDENTICAL": "", # In this case the response is written exactly as expected + "UNIT_COMPARISON_SIMILAR": "", # In this case the response is written similarly to what is expected + "PREFIX_IS_LARGE": "The quantity can be written with fewer digits by using a smaller prefix.", + "PREFIX_IS_SMALL": "The quantity can be written with fewer digits by using a larger prefix.", +}[tag] +feedback_string_generators["COMPARISON"] = lambda tag: lambda inputs: { + "TRUE": "", # TODO: Replace with more specialised messages for different comparisons + "FALSE": "", # TODO: Replace with more specialised messages for different comparisons +}[tag] diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py new file mode 100644 index 0000000..ce74afd --- /dev/null +++ b/app/feedback/symbolic.py @@ -0,0 +1,71 @@ +# Format for feedback string entry: criteria["eval_tag"]("criteria_tag", inputs) = "formatted string" | None +criteria_equivalences = { + **{ + eq: "response=answer" for eq in [ + "answer=response", + "answer-response=0", + "-answer+response=0", + "answer/response=1", + "response/answer-1=0" + ] + } +} +feedback_generators = dict() +feedback_generators["EQUIVALENCES"] = criteria_equivalences +feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { + "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name','')} might be ambiguous, use `Abs(.)` instead of `|.|`", + "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.", + "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.", + "EXPRESSION_NOT_EQUALITY": "The response was an expression but was expected to be an equality.", + "EQUALITY_NOT_EXPRESSION": "The response was an equality but was expected to be an expression.", + "EQUALITIES_EQUIVALENT": None, + "EQUALITIES_NOT_EQUIVALENT": "The response is not the expected equality.", + "WITHIN_TOLERANCE": None, # "The difference between the response the answer is within specified error tolerance.", + "NOT_NUMERICAL": None, # "The expression cannot be evaluated numerically.", +}[tag] +feedback_generators["GENERIC"] = lambda tag: lambda inputs: { + "TRUE": None, + "FALSE": f"{inputs['criterion'].content_string()} is false.", + "UNKNOWN": f"Cannot determine if {inputs['criterion'].content_string()} is true or false.", +}[tag] +feedback_generators["response=answer"] = lambda tag: lambda inputs: { + "TRUE": None, # "The response is equal to the expected answer.", + "FALSE": None, # "The response is not equal to the expected answer.", + "UNKNOWN": None, # "Cannot determine if answer is equal to response.", +}[tag] +feedback_generators["response=answer_where"] = lambda tag: lambda inputs: { + "TRUE": None, # "The response is equal to the expected value.", + "FALSE": None, # "The response is not equal to the expected value.", +}[tag] +feedback_generators["IDENTIFY_REASON"] = lambda tag: lambda inputs: { + "UNKNOWN": None, + "ONE_ADDITION_TO_SUBTRACTION": f"{inputs['criterion'].children[0].content_string()} if one addition is changed to a subtraction or vice versa.", + "ONE_EXPONENT_FLIP": f"{inputs['criterion'].children[0].content_string()} is true if one exponent has its sign changed.", + "ONE_SWAP_ADDITION_AND_MULTIPLICATION": f"{inputs['criterion'].children[0].content_string()} is true if one addition is replaced with a multiplication or vice versa.", +}[tag] +feedback_generators["GET_CANDIDATES"] = lambda tag: lambda inputs: None +feedback_generators["WRITTEN_AS"] = lambda tag: lambda inputs: { + "NUMBER": None, + "CARTESIAN": None, + "EXPONENTIAL": None, + "UNKNOWN": None, +}[tag] +feedback_generators["SYNTACTICAL_EQUIVALENCE"] = lambda tag: lambda inputs: { + "TRUE": None, + "FALSE": None, + "UNKNOWN": None, +}[tag] +feedback_generators["SAME_SYMBOLS"] = lambda tag: lambda inputs: { + "TRUE": None, + "FALSE": "The response can be simplified further.", +}[tag] +feedback_generators["SAME_FORM"] = lambda tag: lambda inputs: { + "CARTESIAN": "Response and answer are both written on Cartesian form.", # None, + "EXPONENTIAL": "Response and answer are both written on exponential form.", # None, + "UNKNOWN": "The response is not written on the expected form.", +}[tag] diff --git a/app/preview.py b/app/preview.py new file mode 100644 index 0000000..cebefd6 --- /dev/null +++ b/app/preview.py @@ -0,0 +1,36 @@ +from .utility.preview_utilities import ( + Params, + Result, +) + +from .preview_implementations.physical_quantity_preview import preview_function as physical_quantity_preview +from .preview_implementations.symbolic_preview import preview_function as symbolic_preview + + +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. + """ + + if params.get("physical_quantity", False): + result = physical_quantity_preview(response, params) + else: + result = symbolic_preview(response, params) + + return result diff --git a/app/preview_implementations/__init__.py b/app/preview_implementations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/evaluation_function/quantity_comparison_preview.py b/app/preview_implementations/physical_quantity_preview.py similarity index 71% rename from evaluation_function/quantity_comparison_preview.py rename to app/preview_implementations/physical_quantity_preview.py index 788f0cf..58ad47a 100644 --- a/evaluation_function/quantity_comparison_preview.py +++ b/app/preview_implementations/physical_quantity_preview.py @@ -1,33 +1,39 @@ -from sympy.parsing.sympy_parser import T as parser_transformations -from .expression_utilities import ( - extract_latex, - convert_absolute_notation, - create_expression_set, - create_sympy_parsing_params, +from copy import deepcopy + +from ..utility.expression_utilities import ( find_matching_parenthesis, - latex_symbols, parse_expression, - substitute_input_symbols, SymbolDict, - sympy_symbols, sympy_to_latex, ) -from .preview_utilities import ( +from ..utility.preview_utilities import ( Params, Preview, Result, - extract_latex, parse_latex, sanitise_latex, ) -from .slr_quantity import SLR_quantity_parser -from .slr_quantity import SLR_quantity_parsing as quantity_parsing +from ..utility.expression_utilities import default_parameters as symbolic_default_parameters +from ..utility.physical_quantity_utilities import SLR_quantity_parser as quantity_parser +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 = ['^','**'] + exponents_notation = ['^', '**'] for notation in exponents_notation: index = 0 while index < len(response): @@ -36,7 +42,7 @@ def fix_exponents(response): processed_response.append(response[index:exponent_start]) exponent_start += len(notation) processed_response.append("**") - exponent_end = find_matching_parenthesis(response, exponent_start, delimiters=('{','}')) + 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) @@ -50,6 +56,7 @@ def fix_exponents(response): processed_response = [] return response + def preview_function(response: str, params: Params) -> Result: """ Function used to preview a student response. @@ -70,6 +77,9 @@ def preview_function(response: str, params: Params) -> Result: 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: @@ -78,18 +88,18 @@ def preview_function(response: str, params: Params) -> Result: latex_out = "" sympy_out = "" - quantity_parser = SLR_quantity_parser(params) + 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, quantity_parser, "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) + 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) @@ -104,13 +114,13 @@ def preview_function(response: str, params: Params) -> Result: unit_sympy = res_parsed.unit.content_string() if unit is not None else "" sympy_out = value_sympy+separator_sympy+unit_sympy else: - res_parsed = quantity_parsing(response, params, quantity_parser, "response") + res_parsed = quantity_parsing(response, params, parser, "response") latex_out = res_parsed.latex_string sympy_out = response except SyntaxError as e: - raise ValueError("Failed to parse Sympy expression") from e + raise Exception("Failed to parse Sympy expression") from e except ValueError as e: - raise ValueError("Failed to parse LaTeX expression") from e + raise Exception("Failed to parse LaTeX expression") from e return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) diff --git a/evaluation_function/symbolic_comparison_preview.py b/app/preview_implementations/symbolic_preview.py similarity index 70% rename from evaluation_function/symbolic_comparison_preview.py rename to app/preview_implementations/symbolic_preview.py index 7f4ada3..95b03c3 100644 --- a/evaluation_function/symbolic_comparison_preview.py +++ b/app/preview_implementations/symbolic_preview.py @@ -1,28 +1,27 @@ from sympy.parsing.sympy_parser import T as parser_transformations -from .expression_utilities import ( - extract_latex, +from ..utility.expression_utilities import ( + default_parameters, convert_absolute_notation, create_expression_set, create_sympy_parsing_params, - latex_symbols, parse_expression, substitute_input_symbols, SymbolDict, sympy_symbols, sympy_to_latex, + preprocess_expression, ) - -from .preview_utilities import ( +from ..utility.preview_utilities import ( Params, Preview, Result, - extract_latex, parse_latex ) +from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from .feedback.symbolic_comparison import internal as symbolic_comparison_internal_messages 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 = [] @@ -48,7 +47,7 @@ def parse_symbolic(response: str, params): 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)) from exc + raise SyntaxError(symbolic_comparison_internal_messages("PARSE_ERROR")({"response": response})) from exc result_sympy_expression.append(res) return result_sympy_expression, feedback @@ -74,6 +73,10 @@ def preview_function(response: str, params: Params) -> Result: 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", {}) @@ -87,25 +90,30 @@ def preview_function(response: str, params: Params) -> Result: for response in response_list: try: - if params.get("is_latex", False): - response = parse_latex(response, symbols) - - params.update({"rationalise": False}) - expression_list, _ = parse_symbolic(response, params) - - 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(str(expression)) - + 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(str(expression)) + if len(sympy_out) == 1: sympy_out = sympy_out[0] sympy_out = str(sympy_out) - - if not params.get("is_latex", False): - sympy_out = response - + if len(latex_out) > 1: latex_out = "\\left\\{"+",~".join(latex_out)+"\\right\\}" else: diff --git a/evaluation_function/preview_tests.py b/app/preview_tests.py similarity index 88% rename from evaluation_function/preview_tests.py rename to app/preview_tests.py index b82fa00..c18f98d 100644 --- a/evaluation_function/preview_tests.py +++ b/app/preview_tests.py @@ -1,9 +1,10 @@ import os import pytest -from .preview_utilities import Params, extract_latex +from .utility.preview_utilities import Params from .preview import preview_function + class TestPreviewFunction(): """ TestCase Class used to test the algorithm. @@ -24,10 +25,10 @@ class TestPreviewFunction(): """ # Import tests that makes sure that mathematical expression comparison works as expected - from .symbolic_comparison_preview_tests import TestPreviewFunction as TestSymbolicComparison + from .tests.symbolic_preview_tests import TestPreviewFunction as TestSymbolicComparison # Import tests that makes sure that physical quantities are handled as expected - from .quantity_comparison_preview_tests import TestPreviewFunction as TestQuantityComparison + from .tests.physical_quantity_preview_tests import TestPreviewFunction as TestQuantityComparison def test_empty_latex_expression(self): response = "" @@ -75,5 +76,6 @@ def test_natural_logarithm_notation(self): preview = result["preview"] assert preview["latex"] == r"\ln{\left(x \right)}" + if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) + pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/evaluation_function/requirements.txt b/app/requirements.txt similarity index 91% rename from evaluation_function/requirements.txt rename to app/requirements.txt index 726dccf..1ac96c7 100644 --- a/evaluation_function/requirements.txt +++ b/app/requirements.txt @@ -1,6 +1,6 @@ pydot typing_extensions mpmath==1.2.1 -sympy==1.10.1 +sympy==1.12 antlr4-python3-runtime==4.7.2 git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2 \ No newline at end of file diff --git a/app/tests/example_tests.py b/app/tests/example_tests.py new file mode 100644 index 0000000..8235f61 --- /dev/null +++ b/app/tests/example_tests.py @@ -0,0 +1,512 @@ +import os +import pytest + +from ..evaluation import evaluation_function +from ..preview import preview_function + + +class TestEvaluationFunction(): + """ + 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. + """ + + @pytest.mark.parametrize( + "assumptions,value", + [ + (None, False), + ("('a','positive') ('b','positive')", True), + ] + ) + def test_setting_input_symbols_to_be_assumed_positive_to_avoid_issues_with_fractional_powers(self, assumptions, value): + response = "sqrt(a)/sqrt(b)" + answer = "sqrt(a/b)" + params = { + "strict_syntax": False, + "elementary_functions": True, + } + if assumptions is not None: + params.update({"symbol_assumptions": assumptions}) + preview = preview_function(response, params)["preview"] + result = evaluation_function(response, answer, params) + assert preview["latex"] == r"\frac{\sqrt{a}}{\sqrt{b}}" + assert result["is_correct"] == value + + @pytest.mark.parametrize( + "response, is_latex, response_latex", + [ + (r"\pm x^{2}+\mp y^{2}", True, r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), + ("plus_minus 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", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}"), + ("- minus_plus x^2 - plus_minus y^2", False, r"\left\{x^{2} - y^{2},~- x^{2} - - y^{2}\right\}"), + ("pm x**2 + mp y**2", False, r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), + ("+- x**2 + -+ y**2", False, r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), + ] + ) + def test_using_plus_minus_symbols(self, response, is_latex, response_latex): + answer = "plus_minus x**2 + minus_plus y**2" + params = { + "strict_syntax": False, + "elementary_functions": True, + "symbols": { + "plus_minus": { + "latex": r"\(\pm\)", + "aliases": ["pm", "+-"], + }, + "minus_plus": { + "latex": r"\(\mp\)", + "aliases": ["mp", "-+"], + }, + }, + } + if is_latex is True: + processed_response = preview_function(response, {**params, **{"is_latex": True}})["preview"]["sympy"] + result = evaluation_function(processed_response, answer, params) + assert result["is_correct"] is True + 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 + + @pytest.mark.parametrize( + "response, response_latex", + [ + ("x**2-5*y**2-7=0", r"x^{2} - 5 \cdot y^{2} - 7=0"), + ("x^2 = 5y^2+7", r"x^{2}=5 \cdot y^{2} + 7"), + ("2x^2 = 10y^2+14", r"2 \cdot x^{2}=10 \cdot y^{2} + 14"), + ] + ) + def test_equalities_in_the_answer_and_response(self, response, response_latex): + answer = "x**2-5*y**2-7=0" + params = { + "strict_syntax": False, + "elementary_functions": True, + } + preview = preview_function(response, params)["preview"] + result = evaluation_function(response, answer, params) + assert preview["latex"] == response_latex + assert result["is_correct"] is True + + @pytest.mark.parametrize( + "response, answer, response_latex, value, strictness, units_string, tags", + [ + ("2.00 kilometre/hour", "2.00 km/h", r"2.0~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, None, None, set(['response matches answer_TRUE', 'response matches answer_UNIT_COMPARISON_IDENTICAL'])), + ("2.00", "2.00 km/h", r"2.0", False, None, None, set(["response matches answer_MISSING_UNIT"])), + ("kilometre/hour", "2.00 km/h", r"\frac{\mathrm{kilometre}}{\mathrm{hour}}", False, None, None, set(["response matches answer_MISSING_VALUE"])), + ("2 km/h", "2.00 km/h", r"2~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, None, None, set(['response matches answer_TRUE', 'response matches answer_UNIT_COMPARISON_IDENTICAL'])), + ("2 km", "2.00 km/h", r"2~\mathrm{kilometre}", False, None, None, {'response matches answer_FALSE', 'response matches answer_DIMENSION_MATCH_FALSE'}), + ("0.56 m/s", "2.00 km/h", r"0.56~\frac{\mathrm{metre}}{\mathrm{second}}", False, None, None, {'response matches answer_FALSE', 'response matches answer_DIMENSION_MATCH_TRUE'}), + ("0.556 m/s", "2.00 km/h", r"0.556~\frac{\mathrm{metre}}{\mathrm{second}}", True, None, None, {'response matches answer_TRUE', 'response matches answer_UNIT_COMPARISON_SIMILAR'}), + ("2000 meter/hour", "2.00 km/h", r"2000~\frac{\mathrm{metre}}{\mathrm{hour}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_PREFIX_IS_SMALL"}), + ("0.002 megametre/hour", "2.00 km/h", r"0.002~\frac{\mathrm{megametre}}{\mathrm{hour}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_PREFIX_IS_LARGE"}), + ("2 metre/millihour", "2.00 km/h", r"2~\frac{\mathrm{metre}}{\mathrm{millihour}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), + ("1.243 mile/hour", "2.00 km/h", r"1.243~\frac{\mathrm{mile}}{\mathrm{hour}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), + ("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"}), + ("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"}), + ] + ) + def test_checking_the_value_of_a_physical_quantity(self, response, answer, response_latex, value, strictness, units_string, tags): + params = { + "strict_syntax": False, + "elementary_functions": True, + "physical_quantity": True, + } + if strictness is not None: + params.update({"strictness": strictness}) + if units_string is not None: + params.update({"units_string": units_string}) + preview = preview_function(response, params)["preview"] + result = evaluation_function(response, answer, params, include_test_data=True) + assert preview["latex"] == response_latex + assert result["response_latex"] == response_latex + assert tags == set(result["tags"]) + assert result["is_correct"] == value + + @pytest.mark.parametrize( + "res,ans,convention,value", + [ + ("1/ab", "1/(ab)", "implicit_higher_precedence", True), + ("1/ab", "1/a*b", "implicit_higher_precedence", False), + ("1/ab", "(1/a)*b", "implicit_higher_precedence", False), + ("1/ab", "1/(ab)", "equal_precedence", False), + ("1/ab", "1/a*b", "equal_precedence", True), + ("1/ab", "(1/a)*b", "equal_precedence", True), + ] + ) + def test_implicit_multiplication_convention(self, res, ans, convention, value): + params = {"strict_syntax": False, "convention": convention} + result = evaluation_function(res, ans, params) + assert result["is_correct"] is value + + @pytest.mark.parametrize( + "answer, atol_response_true, atol_response_false, rtol_response_true, rtol_response_false", + [ + ( + "sqrt(47)+pi", + ["10", "5.1", "14.9"], + ["4.9", "15.1"], + ["10", "5.1", "14.9"], + ["4.9", "15.1"] + ), + ( + "(13/3)^pi", + ["100", "96", "104"], + ["94", "106"], + ["100", "51", "149"], + ["49", "151"], + ), + ( + "9^(e+ln(1.5305))", + ["1000", "996", "1004"], + ["994", "1006"], + ["1000", "501", "1499"], + ["499", "1501"], + ) + ] + ) + 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, + "elementary_functions": True, + "atol": 5, + } + for response in atol_response_true: + result = evaluation_function(response, answer, params) + assert result["is_correct"] is True + for response in atol_response_false: + result = evaluation_function(response, answer, params) + assert result["is_correct"] is False + params = { + "strict_syntax": False, + "elementary_functions": True, + "rtol": 0.5, + } + for response in rtol_response_true: + result = evaluation_function(response, answer, params) + assert result["is_correct"] is True + for response in rtol_response_false: + result = evaluation_function(response, answer, params) + assert result["is_correct"] is False + + @pytest.mark.parametrize( + "response, answer, response_latex, criteria, value, feedback_tags, extra_params", + [ + ( + "exp(lambda*x)/(1+exp(lambda*x))", + "c*exp(lambda*x)/(1+c*exp(lambda*x))", + r"\frac{e^{\lambda \cdot x}}{e^{\lambda \cdot x} + 1}", + "diff(response,x)=lambda*response*(1-response)", + True, + [], + {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}} + ), + ( + "5*exp(lambda*x)/(1+5*exp(lambda*x))", + "c*exp(lambda*x)/(1+c*exp(lambda*x))", + r"\frac{5 \cdot e^{\lambda \cdot x}}{5 \cdot e^{\lambda \cdot x} + 1}", + "diff(response,x)=lambda*response*(1-response)", + True, + [], + {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}} + ), + ( + "6*exp(lambda*x)/(1+7*exp(lambda*x))", + "c*exp(lambda*x)/(1+c*exp(lambda*x))", + r"\frac{6 \cdot e^{\lambda \cdot x}}{7 \cdot e^{\lambda \cdot x} + 1}", + "diff(response,x)=lambda*response*(1-response)", + False, + [], + {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}} + ), + ( + "c*exp(lambda*x)/(1+c*exp(lambda*x))", + "c*exp(lambda*x)/(1+c*exp(lambda*x))", + r"\frac{c \cdot e^{\lambda \cdot x}}{c \cdot e^{\lambda \cdot x} + 1}", + "diff(response,x)=lambda*response*(1-response)", + True, + [], + {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}} + ), + ("5x", "5x", r"5 \cdot x", "answer-response = 0, response/answer = 1", True, ["answer-response = 0_TRUE"], dict()), + ("x", "5x", r"x", "answer-response = 0, response/answer = 1", False, ["answer-response = 0_FALSE"], dict()), + ("2x", "x", r"2 \cdot x", "response=2*answer", True, ["response=2*answer_TRUE"], dict()), + ("x", "x", "x", "response=2*answer", False, ["response=2*answer_FALSE"], dict()), + ("-x", "x", "- x", "answer=-response", True, ["answer=-response_TRUE"], dict()), + ("x", "x", "x", "response=-answer", False, ["response=-answer_FALSE"], dict()), + ("1", "1", "1", "response^3-6*response^2+11*response-6=0", True, [], dict()), + ("2", "1", "2", "response^3-6*response^2+11*response-6=0", True, [], dict()), + ("3", "1", "3", "response^3-6*response^2+11*response-6=0", True, [], dict()), + ("4", "1", "4", "response^3-6*response^2+11*response-6=0", False, [], dict()), + ("sin(x)+2", "sin(x)", r"\sin{\left(x \right)} + 2", "Derivative(response,x)=cos(x)", True, [], dict()), + ("sin(x)+2", "sin(x)", r"\sin{\left(x \right)} + 2", "diff(response,x)=cos(x)", True, [], dict()), + ("cos(x)+2", "sin(x)", r"\cos{\left(x \right)} + 2", "diff(response,x)=cos(x)", False, [], dict()), + ] + ) + def test_customizing_comparison(self, response, answer, response_latex, criteria, value, feedback_tags, extra_params): + params = { + "strict_syntax": False, + "elementary_functions": True, + "criteria": criteria, + } + params.update(extra_params) + preview = preview_function(response, params)["preview"] + result = evaluation_function(response, answer, params, include_test_data=True) + assert preview["latex"] == response_latex + assert result["response_latex"] == response_latex + assert result["is_correct"] == value + for feedback_tag in feedback_tags: + assert feedback_tag in result["tags"] + + @pytest.mark.parametrize("response", ["epsilon_r", "eps", "eps_r", "e_r"]) + def test_using_input_symbols_alternatives(self, response): + answer = "epsilon_r" + params = { + "strict_syntax": False, + "elementary_functions": True, + "symbols": { + "epsilon_r": { + "latex": r"\(\epsilon_r\)", + "aliases": ["eps", "eps_r", "e_r"], + }, + }, + } + preview = preview_function(response, params)["preview"] + result = evaluation_function(response, answer, params) + assert preview["latex"] == r"\epsilon_r" + assert result["is_correct"] is True + + @pytest.mark.parametrize( + "response,value", + [ + ("k*alpha*(d^2 T)/(dx^2) = k*(dT/dt) - alpha*q_dot", True), + ("k*alpha*(d^2 T)/(dx^2) = k*(dT/dt) + alpha*q_dot", False), + ("d^2T/dx^2 + q_dot/k = 1/alpha*(dT/dt)", True), + ("d^2 T/dx^2 + q_dot/k = 1/alpha*(dT/dt)", True), + ("(d^2 T)/(dx^2) + q_dot/k = 1/alpha*(dT/dt)", True), + ("Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)", True), + ] + ) + def test_MECH50001_2_24_a(self, response, value): + params = { + "strict_syntax": False, + "elementary_functions": True, + "symbol_assumptions": "('alpha','constant') ('k','constant') ('T','function') ('q','function')", + 'symbols': { + 'alpha': {'aliases': [], 'latex': r'\alpha'}, + 'Derivative(q(x,t),t)': {'aliases': ['q_{dot}', 'q_dot'], 'latex': r'\dot{q}'}, + 'Derivative(T(x,t),t)': {'aliases': ['dT/dt'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}t}'}, + 'Derivative(T(x,t),x)': {'aliases': ['dT/dx'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}x}'}, + 'Derivative(T(x,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}'}, + }, + } + answer = "(d^2 T)/(dx^2) + q_dot/k = 1/alpha*(dT/dt)" + result = evaluation_function(response, answer, params) + assert result["is_correct"] is value + + def test_incorrect_response_with_custom_feedback(self): + response = "x+1" + answer = "x+2" + response = evaluation_function(response, answer, {"feedback_for_incorrect_response": "Custom feedback"}) + assert response["is_correct"] is False + assert response["feedback"] == "Custom feedback" + + @pytest.mark.parametrize( + "response, answer, criteria, value, feedback_tags, additional_params", + [ + ( + "2+2*I", + "2+2*I", + "answer=response", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + { + "symbols": {"I": {"aliases": ["i", "j"], "latex": r"\(i\)"}}, + "complexNumbers": True, + } + ), + ( + "2+2I", + "2+2*I", + "answer=response", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + { + "symbols": {"I": {"aliases": ["i", "j"], "latex": r"\(i\)"}}, + "complexNumbers": True, + } + ), + ( + "2.00+2.00*I", + "2+2*I", + "answer=response", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + { + "symbols": {"I": {"aliases": ["i", "j"], "latex": r"\(i\)"}}, + "complexNumbers": True, + } + ), + ( + "2*I+2", + "2+2*I", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + { + "symbols": {"I": {"aliases": ["i", "j"], "latex": r"\(i\)"}}, + "complexNumbers": True, + } + ), + ( + "(x-5)^2-6", + "(x-4)^2-5", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-4)^2-5", + "(x-4)^2-5", + "answer=response", + True, + [ + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-4)^2 - 5", + "(x-4)^2-5", + "answer=response", + True, + [ + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "x^2-8x+11", + "(x-4)^2-5", + "answer=response", + False, + [ + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_FALSE" + ], + {"detailed_feedback": True} + ), + ( + "(x-3)^2-3", + "(x-4)^2-5", + "answer=response", + False, + [ + "answer=response_FALSE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x+4)^2-5", + "(x+(-4))^2-5", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-4)^2+5", + "(x-4)^2+(-5)", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x+4)^2+5", + "(x+(-4))^2+(-5)", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ] + ) + def test_syntactical_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): + params = { + "strict_syntax": False, + "elementary_functions": True, + "syntactical_comparison": True, + "criteria": criteria, + } + params.update(additional_params) + result = evaluation_function(response, answer, params, include_test_data=True) + assert result["is_correct"] is value + assert set(feedback_tags) == set(result["tags"]) + + +if __name__ == "__main__": + pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/evaluation_function/quantity_comparison_evaluation_tests.py b/app/tests/physical_quantity_evaluation_tests.py similarity index 63% rename from evaluation_function/quantity_comparison_evaluation_tests.py rename to app/tests/physical_quantity_evaluation_tests.py index d263a39..77a0be5 100644 --- a/evaluation_function/quantity_comparison_evaluation_tests.py +++ b/app/tests/physical_quantity_evaluation_tests.py @@ -3,8 +3,8 @@ # Import necessary data and reference cases for tests from .slr_quantity_tests import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases -from .evaluation import evaluation_function -from .unit_system_conversions import ( +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, @@ -45,21 +45,33 @@ def log_details_to_file(self, details, filename): @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) def test_strict_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): - params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict"} + params = { + "strict_syntax": False, + "physical_quantity": True, + "units_string": "SI", + "strictness": "strict", + "elementary_functions": True + } answer = string response = string result = evaluation_function(response, answer, params) assert result["is_correct"] is True - assert result["response_latex"] == "~".join([latex for latex in [value_latex,unit_latex] if latex is not None]) + 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) def test_natural_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): - params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "natural"} + params = { + "strict_syntax": False, + "physical_quantity": True, + "units_string": "SI", + "strictness": "natural", + "elementary_functions": True + } answer = string response = string result = evaluation_function(response, answer, params) assert result["is_correct"] is True - assert result["response_latex"] == "~".join([latex for latex in [value_latex,unit_latex] if latex is not None]) + assert result["response_latex"] == "~".join([latex for latex in [value_latex, unit_latex] if latex is not None]) @pytest.mark.skip("Too resource intensive") def test_slow_quantity_alternative_names_natural_syntax(self): @@ -80,10 +92,8 @@ def test_slow_quantity_alternative_names_natural_syntax(self): response = prefix[0]+u1_alt+u2_alt try: result = evaluation_function(response, answer, params) - except Exception as e: + except Exception: errors.append((answer, response)) - print((answer, response)) - print(e) continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) @@ -132,20 +142,20 @@ def test_slow_quantity_short_forms_natural_syntax(self): @pytest.mark.parametrize( "value,unit,small_diff,large_diff", [ - ("10.5", "kg m/s^2", 0.04, 0.06), - ("10.55", "kg m/s^2", 0.004, 0.006), - ("0.105", "kg m/s^2", 0.0004, 0.0006), - ("0.0010", "kg m/s^2", 0.00004, 0.00006), - ("100", "kg m/s^2", 0.4, 0.6), - ("100e10", "kg m/s^2", 4e9, 6e9) + ("10.5", "kg m/s^2", 0.04, 0.06), + ("10.55", "kg m/s^2", 0.004, 0.006), + ("0.105", "kg m/s^2", 0.0004, 0.0006), + ("0.0010", "kg m/s^2", 0.00004, 0.00006), + ("100", "kg m/s^2", 0.4, 0.6), + ("100*10**10", "kg m/s^2", 4*10**9, 6*10**9) ] ) def test_compute_relative_tolerance_from_significant_digits(self, value, unit, small_diff, large_diff): ans = value+" "+unit - res_correct_under = str(float(value)-small_diff)+" "+unit - res_correct_over = str(float(value)+small_diff)+" "+unit - res_incorrect_under = str(float(value)-large_diff)+" "+unit - res_incorrect_over = str(float(value)+large_diff)+" "+unit + res_correct_under = str(eval(value)-small_diff)+" "+unit + res_correct_over = str(eval(value)+small_diff)+" "+unit + res_incorrect_under = str(eval(value)-large_diff)+" "+unit + res_incorrect_over = str(eval(value)+large_diff)+" "+unit params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict"} assert evaluation_function(res_correct_under, ans, params)["is_correct"] is True assert evaluation_function(res_correct_over, ans, params)["is_correct"] is True @@ -158,7 +168,8 @@ def test_compute_relative_tolerance_from_significant_digits(self, value, unit, s ("-10500 g m/s^2", "-10.5 kg m/s^2"), ("-10.5 mm^2", "-0.0000105 m^2"), ("5 GN", "5000000000 metre kilogram second^(-2)"), - ("10 pint", "5682.6 centimetre^3") + ("10 pint", "5682.6 centimetre^3"), + ("1 kg", "2.204 lb") ] ) def test_convert_units(self, ans, res): @@ -169,10 +180,10 @@ def test_convert_units(self, ans, res): @pytest.mark.parametrize( "ans,res,tag", [ - ("-10.5 kg m/s^2", "kg m/s^2", "MISSING_VALUE"), - ("-10.5 kg m/s^2", "-10.5", "MISSING_UNIT"), - ("kg m/s^2", "-10.5 kg m/s^2", "UNEXPECTED_VALUE"), - ("-10.5", "-10.5 kg m/s^2", "UNEXPECTED_UNIT") + ("-10.5 kg m/s^2", "kg m/s^2", "response matches answer_MISSING_VALUE"), + ("-10.5 kg m/s^2", "-10.5", "response matches answer_MISSING_UNIT"), + ("kg m/s^2", "-10.5 kg m/s^2", "response matches answer_UNEXPECTED_VALUE"), + ("-10.5", "-10.5 kg m/s^2", "response matches answer_UNEXPECTED_UNIT") ] ) def test_si_units_check_tag(self, ans, res, tag): @@ -186,29 +197,35 @@ def test_si_units_parse_error(self): res = "-10.5 kg m/s^" params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict"} result = evaluation_function(res, ans, params, include_test_data=True) - assert "PARSE_EXCEPTION" in result["tags"] + assert "PARSE_ERROR_response" in result["tags"] assert result["is_correct"] is False @pytest.mark.parametrize( "res,is_correct,tag", [ - ("-10.5 kilogram metre/second^2", True, "RESPONSE_MATCHES_ANSWER"), - ("-10.5 kilogram m/s^2", True, "RESPONSE_MATCHES_ANSWER"), - ("-10.5 kg m/s^2", True, "RESPONSE_MATCHES_ANSWER"), - ("-0.5 kg m/s^2+10 kg m/s^2", False, "REVERTED_UNIT"), - ("-10500 g m/s^2", True, "PREFIX_IS_SMALL"), - ("-10.46 kg m/s^2", True, "RESPONSE_MATCHES_ANSWER"), - ("-10.54 kg m/s^2", True, "RESPONSE_MATCHES_ANSWER"), - ("-10.44 kg m/s^2", False, "RESPONSE_MATCHES_ANSWER"), - ("-10.56 kg m/s^2", False, "RESPONSE_MATCHES_ANSWER"), - ("-10.5", False, "MISSING_UNIT"), - ("kg m/s^2", False, "MISSING_VALUE"), - ("-sin(pi/2)*sqrt(441)^(0.77233) kg m/s^2", True, "RESPONSE_MATCHES_ANSWER"), + ("-10.5 kilogram metre/second^2", True, "response matches answer_TRUE"), + ("-10.5 kilogram m/s^2", True, "response matches answer_TRUE"), + ("-10.5 kg m/s^2", True, "response matches answer_TRUE"), + ("-0.5 kg m/s^2+10 kg m/s^2", False, "response_REVERTED_UNIT_3"), + ("-10500 g m/s^2", True, "response matches answer_UNIT_COMPARISON_PREFIX_IS_SMALL"), + ("-10.46 kg m/s^2", True, "response matches answer_TRUE"), + ("-10.54 kg m/s^2", True, "response matches answer_TRUE"), + ("-10.44 kg m/s^2", False, "response matches answer_FALSE"), + ("-10.56 kg m/s^2", False, "response matches answer_FALSE"), + ("-10.5", False, "response matches answer_MISSING_UNIT"), + ("kg m/s^2", False, "response matches answer_MISSING_VALUE"), + ("-sin(pi/2)*sqrt(441)^(0.77233) kg m/s^2", True, "response matches answer_TRUE"), ] ) def test_demo_si_units_demo_a(self, res, is_correct, tag): ans = "-10.5 kilogram metre/second^2" - params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI", "strictness": "strict"} + params = { + "strict_syntax": False, + "physical_quantity": True, + "units_string": "SI", + "strictness": "strict", + "elementary_functions": True, + } result = evaluation_function(res, ans, params, include_test_data=True) assert tag in result["tags"] assert result["is_correct"] is is_correct @@ -216,10 +233,10 @@ def test_demo_si_units_demo_a(self, res, is_correct, tag): @pytest.mark.parametrize( "res,ans,is_correct,tag,latex", [ - ("-10.5", "-10.5", True, "RESPONSE_MATCHES_ANSWER", r"-10.5"), - ("-10.5 kg m/s^2", "-10.5", False, "UNEXPECTED_UNIT", r"-10.5~\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}"), - ("kg m/s^2", "kg m/s^2", True, "RESPONSE_MATCHES_ANSWER", r"\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}"), - ("-10.5 kg m/s^2", "kg m/s^2", False, "UNEXPECTED_VALUE", r"-10.5~\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}"), + ("-10.5", "-10.5", True, "response matches answer_TRUE", r"-10.5"), + ("-10.5 kg m/s^2", "-10.5", False, "response matches answer_UNEXPECTED_UNIT", r"-10.5~\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}"), + ("kg m/s^2", "kg m/s^2", True, "response matches answer_TRUE", r"\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}"), + ("-10.5 kg m/s^2", "kg m/s^2", False, "response matches answer_UNEXPECTED_VALUE", r"-10.5~\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}"), ] ) def test_demo_si_units_demo_b(self, res, ans, is_correct, tag, latex): @@ -240,5 +257,74 @@ def test_MECH60001_dynamic_signals_error_with_dB(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is True + def test_quantity_with_multiple_of_positive_value(self): + ans = "5 Hz" + res = "10 Hz" + params = { + "strict_syntax": False, + "physical_quantity": True, + "elementary functions": True, + "criteria": "response > answer" + } + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + + def test_radians_to_frequency(self): + ans = "2*pi*f radian/second" + res = "f Hz" + params = { + "strict_syntax": False, + "physical_quantity": True, + "elementary functions": True + } + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + + def test_print_floating_point_approximation_of_very_large_numbers(self): + ans = "2.47*10^4 kg/s" + res = "2.47**10^4 kg/s" # This number is large enough than attempting to turn it into a string will cause an error + params = { + 'rtol': 0.005, + 'comparison': 'expression', + 'strict_syntax': False, + 'physical_quantity': True, + } + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is False + + def test_legacy_strictness(self): + ans = "100*kilo*pascal*ohm" + res = "100 kilopascal ohm" + params = { + 'strict_syntax': False, + 'physical_quantity': True, + 'strictness': 'legacy', + } + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + ans = "8650*watt" + res = "8.65kW" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + res = "8650W" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + res = "8650*W" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + res = "8.65 k W" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + res = "8.65 k*W" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + res = "(8.65)kW" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + res = "(8650)W" + result = evaluation_function(res, ans, params, include_test_data=True) + assert result["is_correct"] is True + + if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) + pytest.main(['-xk not slow', "--no-header", os.path.abspath(__file__)]) diff --git a/evaluation_function/quantity_comparison_preview_tests.py b/app/tests/physical_quantity_preview_tests.py similarity index 75% rename from evaluation_function/quantity_comparison_preview_tests.py rename to app/tests/physical_quantity_preview_tests.py index 38c15e8..d343e39 100644 --- a/evaluation_function/quantity_comparison_preview_tests.py +++ b/app/tests/physical_quantity_preview_tests.py @@ -1,8 +1,7 @@ import os import pytest -from .preview_utilities import Params, extract_latex -from .quantity_comparison_preview import preview_function +from ..preview_implementations.physical_quantity_preview import preview_function from .slr_quantity_tests import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases @@ -27,7 +26,13 @@ class TestPreviewFunction(): @pytest.mark.parametrize("response,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) 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"} + params = { + "strict_syntax": False, + "physical_quantity": True, + "units_string": "SI", + "strictness": "strict", + "elementary_functions": True + } result = preview_function(response, params)["preview"] latex = "" if value_latex is None and unit_latex is not None: @@ -40,7 +45,13 @@ def test_strict_syntax_cases(self, response, value, unit, content, value_latex, @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"} + params = { + "strict_syntax": False, + "physical_quantity": True, + "units_string": "SI", + "strictness": "natural", + "elementary_functions": True + } result = preview_function(response, params)["preview"] latex = "" if value_latex is None and unit_latex is not None: @@ -51,7 +62,8 @@ def test_natural_syntax_cases(self, response, value, unit, content, value_latex, latex = value_latex+"~"+unit_latex assert result["latex"] == latex - @pytest.mark.parametrize("response,preview_latex,preview_sympy", + @pytest.mark.parametrize( + "response,preview_latex,preview_sympy", [ ("sin(123)", r"\sin{\left(123 \right)}", "sin(123)"), ("sqrt(162)", r"\sqrt{162}", "sqrt(162)"), @@ -72,10 +84,11 @@ def test_handwritten_input(self): "physical_quantity": True, "elementary_functions": True, } - response = "\\sqrt{162} \\mathrm{~N} / \\mathrm{m}^{2}" + response = "162 \\mathrm{~N} / \\mathrm{m}^{2}" result = preview_function(response, params)["preview"] - assert result["latex"] == r'\sqrt{162}~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}' # TODO: Fix so that unnecessary parenthesis are simplified away - assert result["sympy"] == "sqrt(162) newton/metre**(2)" + assert result["latex"] == r'162~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}' # TODO: Fix so that unnecessary parenthesis are simplified away + assert result["sympy"] == "162 newton/metre**(2)" + if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) + pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/evaluation_function/slr_quantity_tests.py b/app/tests/slr_quantity_tests.py similarity index 87% rename from evaluation_function/slr_quantity_tests.py rename to app/tests/slr_quantity_tests.py index f8de1aa..5f0ac5a 100644 --- a/evaluation_function/slr_quantity_tests.py +++ b/app/tests/slr_quantity_tests.py @@ -1,8 +1,8 @@ import pytest import os -from .slr_quantity import SLR_quantity_parser, SLR_quantity_parsing -from .unit_system_conversions import\ +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 @@ -13,7 +13,7 @@ "q", # Content of physical quantity after parsing "q", # Expected LaTeX for value None, # Expected LaTeX for unit - ["NO_UNIT"]), # criteria + ["response = answer_QUANTITY_MATCH"]), # criteria ("10", "10", None, @@ -162,7 +162,7 @@ "10 gram/metresecond", None, "10 gram/metresecond", - r"\frac{10 \cdot a \cdot c \cdot d \cdot e \cdot e \cdot e \cdot g \cdot m \cdot n \cdot o \cdot r \cdot r \cdot s \cdot t}{m}", + r"\frac{10 \cdot e \cdot c \cdot d \cdot gram \cdot n \cdot o}{metres}", None, ["NO_UNIT", "EXPR_VALUE"]), ("10 g/sm", @@ -175,7 +175,7 @@ ("10 s/g + 5 gram*second^2 + 7 ms + 5 gram/second^3", "10 s/g + 5 gram*second^2 + 7 ms + 5", "gram/second^3", "10 s/g + 5 gram*second^2 + 7 ms + 5 gram/second^3", - r"5 \cdot a \cdot c \cdot d^{2} \cdot e \cdot g \cdot m \cdot n \cdot o \cdot r \cdot s + 7 \cdot m \cdot s + 5 + \frac{10 \cdot s}{g}", + r"5 \cdot gram \cdot second^{2} + 7 \cdot m \cdot s + 5 + \frac{10 \cdot s}{g}", r"\frac{\mathrm{gram}}{\mathrm{second}^{3}}", ["FULL_QUANTITY", "EXPR_VALUE", "REVERTED_UNIT"]), ("10 kg m/s^2 + 10 kg m/s^2", @@ -185,7 +185,7 @@ r"\frac{10 \cdot g \cdot k \cdot m}{s^{2}} + 10", r"\mathrm{kilogram}~\frac{\mathrm{metre}}{\mathrm{second}^{2}}", ["FULL_QUANTITY", "EXPR_VALUE", "REVERTED_UNIT"]), - #("-0.5 kg m/s^2-10 kg m/s^2", + # ("-0.5 kg m/s^2-10 kg m/s^2", # "-0.5 kg m/s^2-10", # "kg m/s^2", # "-0.5 kg m/s^2-10 kilogram metre/second^2", @@ -196,14 +196,14 @@ "10 second/gram * 7 ms * 5", "gram/second", "10 second/gram * 7 ms * 5 gram/second", - r"10 \cdot s \cdot e \cdot c \cdot o \cdot n \cdot d \cdot \frac{1}{g} \cdot r \cdot a \cdot m \cdot 7 \cdot m \cdot s \cdot 5", + r"10 \cdot second \cdot \frac{1}{gram} \cdot 7 \cdot m \cdot s \cdot 5", r"\frac{\mathrm{gram}}{\mathrm{second}}", ["FULL_QUANTITY", "EXPR_VALUE", "REVERTED_UNIT"]), ("pi+metre second+pi", "pi+metre second+pi", None, "pi+metre second+pi", - r"c \cdot d \cdot e \cdot e \cdot e \cdot m \cdot n \cdot o \cdot r \cdot s \cdot t + \pi + \pi", + r"metre \cdot second + \pi + \pi", None, ["EXPR_VALUE", "NO_UNIT", "REVERTED_UNIT"]), ("1/s^2", @@ -360,14 +360,14 @@ "(kilogrammegametre^2)/(fs^4daA)", None, "(kilogrammegametre^2)/(fs^4daA)", - r'\frac{a \cdot a \cdot e \cdot e \cdot e^{2} \cdot g \cdot g \cdot i \cdot k \cdot l \cdot m \cdot m \cdot m \cdot o \cdot r \cdot r \cdot t}{A \cdot a \cdot d \cdot f \cdot s^{4}}', + r'\frac{gram \cdot kilo \cdot mega \cdot metre^{2}}{A \cdot a \cdot d \cdot f \cdot s^{4}}', None, ["NO_UNIT"]), ("(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogrammegametre^2)/(fs^4daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogrammegametre^2)/(fs^4daA)", None, "(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogrammegametre^2)/(fs^4daA)", - r"\frac{a \cdot a \cdot e \cdot e \cdot e^{2} \cdot g \cdot g \cdot i \cdot k \cdot l \cdot m \cdot m \cdot m \cdot o \cdot r \cdot r \cdot t \cdot \left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{4.3}}{A \cdot a \cdot d \cdot f \cdot s^{4}}", + r"\frac{gram \cdot kilo \cdot mega \cdot metre^{2} \cdot \left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{4.3}}{A \cdot a \cdot d \cdot f \cdot s^{4}}", None, ["NO_UNIT"]), ("mmg", @@ -401,7 +401,7 @@ ("10 s/g + 5 gramsecond^2 + 7 ms + 5 gram/second^3", "10 s/g + 5 gramsecond^2 + 7 ms + 5", "gram/second^3", "10 s/g + 5 gramsecond^2 + 7 ms + 5 gram/second^3", - r"5 \cdot a \cdot c \cdot d^{2} \cdot e \cdot g \cdot m \cdot n \cdot o \cdot r \cdot s + 7 \cdot m \cdot s + 5 + \frac{10 \cdot s}{g}", + r"5 \cdot e \cdot c \cdot d^{2} \cdot grams \cdot n \cdot o + 7 \cdot m \cdot s + 5 + \frac{10 \cdot s}{g}", r"\frac{\mathrm{gram}}{\mathrm{second}^{3}}", ["FULL_QUANTITY", "EXPR_VALUE", "REVERTED_UNIT"]), ("10 kgm/s^2 + 10 kgm/s^2", @@ -415,14 +415,14 @@ "10 second/gram * 7 ms * 5", "gram/second", "10 second/gram * 7 ms * 5 gram/second", - r"10 \cdot s \cdot e \cdot c \cdot o \cdot n \cdot d \cdot \frac{1}{g} \cdot r \cdot a \cdot m \cdot 7 \cdot m \cdot s \cdot 5", + r"10 \cdot second \cdot \frac{1}{gram} \cdot 7 \cdot m \cdot s \cdot 5", r"\frac{\mathrm{gram}}{\mathrm{second}}", ["FULL_QUANTITY", "EXPR_VALUE", "REVERTED_UNIT"]), ("pi+metre second+pi", "pi+metre second+pi", None, "pi+metre second+pi", - r"c \cdot d \cdot e \cdot e \cdot e \cdot m \cdot n \cdot o \cdot r \cdot s \cdot t + \pi + \pi", + r"metre \cdot second + \pi + \pi", None, ["EXPR_VALUE", "NO_UNIT", "REVERTED_UNIT"]), ("1/s^2", @@ -452,7 +452,15 @@ 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 = {"strict_syntax": False, "units_string": "SI", "strictness": "strict"} + 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 @@ -471,7 +479,14 @@ def test_strict_si_syntax(self, string, value, unit, content, value_latex, unit_ [(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 = {"strict_syntax": False, "units_string": "SI", "strictness": "strict"} + 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") @@ -482,7 +497,14 @@ def test_short_forms_strict_SI(self, 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 = {"strict_syntax": False, "units_string": "common", "strictness": "strict"} + 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") @@ -493,7 +515,14 @@ def test_short_forms_common_SI(self, 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 = {"strict_syntax": False, "units_string": "imperial", "strictness": "strict"} + 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") @@ -504,7 +533,14 @@ def test_short_forms_imperial(self, 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 = {"strict_syntax": False, "units_string": "SI common imperial", "strictness": "strict"} + 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") @@ -512,7 +548,15 @@ def test_short_forms_all(self, long_form, short_form): @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 = {"strict_syntax": False, "units_string": "SI common imperial", "strictness": "natural"} + 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 diff --git a/evaluation_function/symbolic_comparison_evaluation_tests.py b/app/tests/symbolic_evaluation_tests.py similarity index 74% rename from evaluation_function/symbolic_comparison_evaluation_tests.py rename to app/tests/symbolic_evaluation_tests.py index d839d01..de4b0d1 100644 --- a/evaluation_function/symbolic_comparison_evaluation_tests.py +++ b/app/tests/symbolic_evaluation_tests.py @@ -1,8 +1,8 @@ import pytest import os -from .evaluation import evaluation_function -from .expression_utilities import elementary_functions_names, substitute +from ..evaluation import evaluation_function +from ..utility.expression_utilities import elementary_functions_names, substitute # 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 = [ @@ -22,7 +22,7 @@ ("atan2", "Batan2(1,1)", "B*pi/4", r"\frac{\pi}{4} \cdot B"), # r"B \operatorname{atan2}{\left(1,1 \right)}" ("sinh", "Bsinh(x)+Bcosh(x)", "B*exp(x)", r"B \cdot \sinh{\left(x \right)} + B \cdot \cosh{\left(x \right)}"), ("cosh", "Bcosh(1)", "B*cosh(-1)", r"B \cdot \cosh{\left(1 \right)}"), - ("tanh", "2Btanh(x)/(1+tanh(x)^2)", "B*tanh(2*x)", r"\frac{2 \cdot B \cdot \tanh{\left(x \right)}}{\tanh^{2}{\left(x \right)} + 1}"), # Ideally this case should print tanh(x)^2 instead of tanh^2(x) + # ("tanh", "2Btanh(x)/(1+tanh(x)^2)", "B*tanh(2*x)", r"\frac{2 \cdot B \cdot \tanh{\left(x \right)}}{\tanh^{2}{\left(x \right)} + 1}"), # Ideally this case should print tanh(x)^2 instead of tanh^2(x) ("csch", "Bcsch(x)", "B/sinh(x)", r"B \cdot \operatorname{csch}{\left(x \right)}"), ("sech", "Bsech(x)", "B/cosh(x)", r"B \cdot \operatorname{sech}{\left(x \right)}"), ("asinh", "Basinh(sinh(1))", "B", r"B \cdot \operatorname{asinh}{\left(\sinh{\left(1 \right)} \right)}"), @@ -220,7 +220,7 @@ def test_invalid_user_expression(self): response = "a*(b+c" answer = "a*(b+c)" result = evaluation_function(response, answer, {}, include_test_data=True) - assert "PARSE_ERROR" in result["tags"] + assert "PARSE_ERROR_response" in result["tags"] def test_invalid_author_expression(self): response = "3*x" @@ -357,7 +357,11 @@ def test_special_constants(self, response, answer): generate_input_variations(response="e^(Ix)", answer="cos(x)+I*sin(x)") + generate_input_variations(response="e^(Ix)+e^(-Ix)", answer="2cos(x)") + generate_input_variations(response="1", answer="re(1+2*I)") + - generate_input_variations(response="2", answer="im(1+2*I)") + generate_input_variations(response="2", answer="im(1+2*I)") + + generate_input_variations(response="2+I", answer="conjugate(2-I)") + + generate_input_variations(response="conjugate(2-I)", answer="2+I") + + generate_input_variations(response="re(2-I)-im(2-I)*I", answer="2+I") + + generate_input_variations(response="2+I", answer="re(2-I)-im(2-I)*I") ) def test_complex_numbers(self, response, answer): params = {"complexNumbers": True, "strict_syntax": False, "elementary_functions": True} @@ -388,7 +392,10 @@ def test_special_functions(self, response, answer): ) ) def test_plus_minus_all_correct(self, response, answer): - params = {"strict_syntax": False} + params = { + "strict_syntax": False, + "elementary_functions": True + } result = evaluation_function(response, answer, params) assert result["is_correct"] is True @@ -513,7 +520,7 @@ def test_equality_sign_in_answer_not_response(self, response, answer): params = {"strict_syntax": False} result = evaluation_function(response, answer, params, include_test_data=True) assert result["is_correct"] is False - assert "EXPRESSION_NOT_EQUALITY" in result["tags"] + assert "response = answer_EXPRESSION_NOT_EQUALITY" in result["tags"] @pytest.mark.parametrize( "response,answer", @@ -526,7 +533,7 @@ def test_equality_sign_in_response_not_answer(self, response, answer): params = {"strict_syntax": False} result = evaluation_function(response, answer, params, include_test_data=True) assert result["is_correct"] is False - assert "EQUALITY_NOT_EXPRESSION" in result["tags"] + assert "response = answer_EQUALITY_NOT_EXPRESSION" in result["tags"] def test_empty_old_format_input_symbols_codes_and_alternatives(self): answer = '(1+(gamma-1)/2)((-1)/(gamma-1))' @@ -719,7 +726,7 @@ def test_numerical_comparison_AERO4007(self, description, response, answer, tole assert result["is_correct"] is outcome def test_both_true_and_false_feedback_AERO4700_2_3_a(self): - response ="1/2*log(2)-j*(pi/4 + 2*n*pi)" + response = "1/2*log(2)-j*(pi/4 + 2*n*pi)" answer = "1/2*log(2)-I*(pi/4 plus_minus 2*n*pi)" params = { "rtol": 0, @@ -756,7 +763,7 @@ def test_warning_inappropriate_symbol(self): ), ( '(0,002*6800*v)/1,2', - '(0,002*6800*v)/1.2' + '(0.002*6800*v)/1.2' ), ( '-∞', @@ -772,7 +779,7 @@ def test_error_inappropriate_symbol(self, response, answer): params = {'strict_syntax': True} result = evaluation_function(response, answer, params, include_test_data=True) assert result["is_correct"] is False - assert "PARSE_ERROR" in result["tags"] + assert "PARSE_ERROR_response" in result["tags"] @pytest.mark.parametrize( "description,response", @@ -790,7 +797,7 @@ def test_error_inappropriate_symbol(self, response, answer): def test_empty_response(self, description, response): answer = "5*x" result = evaluation_function(response, answer, {}, include_test_data=True) - assert "NO_RESPONSE" in result["tags"] + assert "RESERVED_EXPRESSION_MISSING_response" in result["tags"] @pytest.mark.parametrize( "description,answer", @@ -811,7 +818,7 @@ def test_empty_answer(self, description, answer): with pytest.raises(Exception) as e: evaluation_function(response, answer, {}) assert e is not None - assert e.value.args[0] == "No answer was given." + assert e.value.args[1] == "RESERVED_EXPRESSION_MISSING_answer" @pytest.mark.parametrize( "description,response,answer,outcome", @@ -986,7 +993,7 @@ def test_implicit_multiplication_convention(self, res, ans, convention): assert result["is_correct"] is True def test_no_reserved_keywords_in_input_symbol_codes(self): - reserved_keywords = ["response", "answer"] + reserved_keywords = ["where", "written as"] params = { "strict_syntax": False, "elementary_functions": True, @@ -1006,7 +1013,8 @@ def test_no_reserved_keywords_in_input_symbol_codes(self): answer = "b+a" with pytest.raises(Exception) as e: evaluation_function(response, answer, params) - assert "`"+"`, `".join(reserved_keywords)+"`" in str(e.value) + for keyword in reserved_keywords: + assert "`"+keyword+"`" in str(e.value) def test_no_reserved_keywords_in_input_symbol_alternatives(self): reserved_keywords = ["response", "answer"] @@ -1032,10 +1040,11 @@ def test_no_reserved_keywords_in_input_symbol_alternatives(self): answer = "b+a" with pytest.raises(Exception) as e: evaluation_function(response, answer, params) - assert "`"+"`, `".join(reserved_keywords)+"`" in str(e.value) + for keyword in reserved_keywords: + assert "`"+keyword+"`" in str(e.value) def test_no_reserved_keywords_in_old_format_input_symbol_codes(self): - reserved_keywords = ["response", "answer"] + reserved_keywords = ["where", "written as"] params = { "strict_syntax": False, "elementary_functions": True, @@ -1048,7 +1057,8 @@ def test_no_reserved_keywords_in_old_format_input_symbol_codes(self): answer = "b+a" with pytest.raises(Exception) as e: evaluation_function(response, answer, params) - assert "`"+"`, `".join(reserved_keywords)+"`" in str(e.value) + for keyword in reserved_keywords: + assert "`"+keyword+"`" in str(e.value) def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): reserved_keywords = ["response", "answer"] @@ -1067,24 +1077,24 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): answer = "b+a" with pytest.raises(Exception) as e: evaluation_function(response, answer, params) - assert "`"+"`, `".join(reserved_keywords)+"`" in str(e.value) + for keyword in reserved_keywords: + assert "`"+keyword+"`" in str(e.value) @pytest.mark.parametrize( "response, answer, criteria, value, feedback_tags, additional_params", [ ("a+b", "b+a", "answer=response", True, ["answer=response_TRUE"], {}), - #("a+b", "b+a", "not(answer=response)", False, [], {}), ("a+b", "b+a", "answer-response=0", True, ["answer-response=0_TRUE"], {}), ("a+b", "b+a", "answer/response=1", True, ["answer/response=1_TRUE"], {}), ("a+b", "b+a", "answer=response, answer-response=0, answer/response=1", True, ["answer=response_TRUE", "answer-response=0_TRUE", "answer/response=1_TRUE"], {}), - ("2a", "a", "response/answer=2", True, ["RESPONSE_DOUBLE_ANSWER"], {}), - ("2a", "a", "2*answer = response", True, ["RESPONSE_DOUBLE_ANSWER"], {}), - ("2a", "a", "answer = response/2", True, ["RESPONSE_DOUBLE_ANSWER"], {}), - ("2a", "a", "response/answer=2, 2*answer = response, answer = response/2", True, ["RESPONSE_DOUBLE_ANSWER"], {}), - ("-a", "a", "answer=-response", True, ["RESPONSE_NEGATIVE_ANSWER"], {}), - ("-a", "a", "answer+response=0", True, ["RESPONSE_NEGATIVE_ANSWER"], {}), - ("-a", "a", "answer/response=-1", True, ["RESPONSE_NEGATIVE_ANSWER"], {}), - ("-a", "a", "answer=-response, answer+response=0, answer/response=-1", True, ["RESPONSE_NEGATIVE_ANSWER"], {}), + ("2a", "a", "response/answer=2", True, ["response/answer=2_TRUE"], {}), + ("2a", "a", "2*answer = response", True, ["2*answer = response_TRUE"], {}), + ("2a", "a", "answer = response/2", True, ["answer = response/2_TRUE"], {}), + ("2a", "a", "response/answer=2, 2*answer = response, answer = response/2", True, ["response/answer=2_TRUE", "2*answer = response_TRUE", "answer = response/2_TRUE"], {}), + ("-a", "a", "answer=-response", True, ["answer=-response_TRUE"], {}), + ("-a", "a", "answer+response=0", True, ["answer+response=0_TRUE"], {}), + ("-a", "a", "answer/response=-1", True, ["answer/response=-1_TRUE"], {}), + ("-a", "a", "answer=-response, answer+response=0, answer/response=-1", True, ["answer=-response_TRUE", "answer+response=0_TRUE", "answer/response=-1_TRUE"], {}), ("1", "1", "response^3-6*response^2+11*response-6=0", True, [], {}), ("2", "1", "response^3-6*response^2+11*response-6=0", True, [], {}), ("3", "1", "response^3-6*response^2+11*response-6=0", True, [], {}), @@ -1122,12 +1132,12 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): } }), ("3", "x+1", "response=answer where x=2", True, ["response=answer where x=2_TRUE"], {}), - ("1", "x+1", "response=answer where x=2", False, ["response=answer where x=2_ONE_ADDITION_TO_SUBTRACTION", "response candidates x - 1"], {}), + ("1", "x+1", "response=answer where x=2", False, ["response=answer where x=2_ONE_ADDITION_TO_SUBTRACTION", "response=answer where x=2_RESPONSE_CANDIDATES_ONE_ADDITION_TO_SUBTRACTION"], {}), ("5/3", "x/y+1", "response=answer where x=2; y=3", True, ["response=answer where x=2; y=3_TRUE"], {}), - ("15", "x/y+1", "response=answer where x=2; y=3", False, ["response=answer where x=2; y=3_ONE_EXPONENT_FLIP"], {}), # NOTE: Sympy represents input as (x+y)/y so flipping the exponent gives (x+y)*y instead of x*y+1 + ("7", "x/y+1", "response=answer where x=2; y=3", False, ["response=answer where x=2; y=3_ONE_EXPONENT_FLIP"], {}), ("-1/3", "x/y+1", "response=answer where x=2; y=3", False, ["response=answer where x=2; y=3_ONE_ADDITION_TO_SUBTRACTION"], {}), ("13", "x+y*z-1", "response=answer where x=2; y=3; z=4", True, [], {}), - ("34", "Ta*(1+(gamma-1)/2*M**2)", "response=answer where Ta=2; gamma=3; M=4", True, ["response=answer where Ta=2; gamma=3; M=4_TRUE"], + ("34", "Ta*(1+(gamma-1)/2*M**2)", "response=answer where Ta=2; gamma=3; M=4", True, ["response=answer where Ta=2; gamma=3; M=4_TRUE"], { 'symbols': { 'Ta': {'aliases': [], 'latex': r'\(T_a\)'}, @@ -1146,7 +1156,7 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): 'cp': {'aliases': [], 'latex': r'\(c_p\)'}, } }), - ("162/37", "(T0b-T0d)/(QR/cp-T0b)", "response=answer where T0d = Ta*(1+(gamma-1)/2*M^2); Ta=2; gamma=3; M=4; QR=5; cp=6; T0b=7", True, ["response=answer where T0d = Ta*(1+( gamma-1)/2*M^2); Ta=2; gamma=3; M=4; QR=5; cp=6; T0b=7_TRUE"], + ("162/37", "(T0b-T0d)/(QR/cp-T0b)", "response=answer where T0d=Ta*(1+(gamma-1)/2*M^2); Ta=2; gamma=3; M=4; QR=5; cp=6; T0b=7", True, ["response=answer where T0d=Ta*(1+( gamma-1)/2*M^2); Ta=2; gamma=3; M=4; QR=5; cp=6; T0b=7_TRUE"], { 'symbols': { 'Ta': {'aliases': [], 'latex': r'\(T_a\)'}, @@ -1158,7 +1168,12 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): } }), ("log(2)/2+I*(7*pi/4)", "1-I", "im(exp(response))=im(answer), re(exp(response))=re(answer)", True, [], {'complexNumbers': True}), - ("log(2)/2+I*(7*pi/4 plus_minus 2*n*pi)", "1-I", "im(exp(response))=im(answer), re(exp(response))=re(answer)", True, [], + ( + "log(2)/2+I*(7*pi/4 plus_minus 2*n*pi)", + "1-I", + "im(exp(response))=im(answer), re(exp(response))=re(answer)", + True, + [], { 'symbols': { 'n': {'aliases': [], 'latex': r'\(n\)'}, @@ -1168,6 +1183,13 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): 'symbol_assumptions': "('n','integer')", } ), + ("-2525", "-2525", "response = a*50 + d*51*25 where a=77; d=-5", True, [], {}), + ("-3150", "-3150", "response = a*50 + d*50*25 where a=373/4; d=-25/4", True, [], {}), + ("4/9", "(2/9*(3*b-2)^(3/2)-b^2/4-b)-(2/9*(3*a-2)^(3/2)-a^2/4-a)", "response = answer where a=2; b=6", True, [], {}), + ("0", "0", "tan(response) = 0", True, [], {}), + ("pi", "0", "tan(response) = 0", True, [], {}), + ("-pi", "0", "tan(response) = 0", True, [], {}), + ("2*pi", "0", "tan(response) = 0", True, [], {}), ] ) def test_criteria_based_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): @@ -1185,7 +1207,16 @@ def test_criteria_based_comparison(self, response, answer, criteria, value, feed @pytest.mark.parametrize( "response, answer, criteria, value, disabled_evaluation_nodes, expected_feedback_tags, disabled_feedback_tags, additional_params", [ - ("8", "x+y*z**2-1", "response=answer where x=4; y=3; z=2", False, ["response=answer where x=4; y=3; z=2_GET_CANDIDATES_ONE_SWAP_ADDITION_AND_MULTIPLICATION"], ["response=answer where x=4; y=3; z=2_ONE_SWAP_ADDITION_AND_MULTIPLICATION"], ["response candidates -x + y*z**2"], {}), + ( + "8", + "x+y*z**2-1", + "response=answer where x=4; y=3; z=2", + False, + ["response=answer where x=4; y=3; z=2_GET_CANDIDATES_ONE_SWAP_ADDITION_AND_MULTIPLICATION"], + ["response=answer where x=4; y=3; z=2_ONE_SWAP_ADDITION_AND_MULTIPLICATION"], + ["response=answer where x=4; y=3; z=2_RESPONSE_CANDIDATES_ONE_SWAP_ADDITION_AND_MULTIPLICATION"], + {} + ), ] ) def test_disabled_evaluation_nodes(self, response, answer, criteria, value, disabled_evaluation_nodes, expected_feedback_tags, disabled_feedback_tags, additional_params): @@ -1206,24 +1237,333 @@ def test_disabled_evaluation_nodes(self, response, answer, criteria, value, disa @pytest.mark.parametrize( "response, answer, criteria, value, feedback_tags, additional_params", [ - ("2", "2", "response=answer", True, ["response=answer_TRUE", "response=answer_SYNTACTICAL_EQUIVALENCE_TRUE", "response=answer_SAME_SYMBOLS_TRUE", "response=answer_SAME_FORM_CARTESIAN"], {}), - ("4/2", "2", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_UNKNOWN"], {}), - ("2+x-x", "2", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_FORM_UNKNOWN", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_SYMBOLS_FALSE"], {}), - ("2+2*I", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_TRUE", "answer=response_SAME_FORM_CARTESIAN"], {}), - ("2+2*I", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_TRUE", "answer=response_SAME_FORM_CARTESIAN"], {"I": {"aliases": ["i","j"], "latex": r"\(i\)"}}), - ("2+2I", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_CARTESIAN"], {}), - ("2.00+2.00*I", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_CARTESIAN"], {}), - ("3+3I", "2+2*I", "answer=response", False, ["answer=response_FALSE", "answer=response_SAME_FORM_CARTESIAN"], {}), - ("2(1+I)", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_UNKNOWN"], {}), - ("2(1+I)", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_UNKNOWN"], {"I": {"aliases": ["i","j"], "latex": r"\(i\)"}}), - ("2I+2", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_UNKNOWN"], {}), - ("4/2+6/3*I", "2+2*I", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_UNKNOWN"], {}), - ("2*e^(2*I)", "2*e^(2*I)", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_TRUE", "answer=response_SAME_FORM_EXPONENTIAL"], {}), - ("2*E^(2*I)", "2*e^(2*I)", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_TRUE", "answer=response_SAME_FORM_EXPONENTIAL"], {}), - ("2*exp(2*I)", "2*e^(2*I)", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_EXPONENTIAL"], {}), - ("2*e**(2*I)", "2*e^(2*I)", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_EXPONENTIAL"], {}), - ("e**(2*I)", "1*e^(2*I)", "answer=response", True, ["answer=response_TRUE", "answer=response_SAME_SYMBOLS_TRUE", "answer=response_SYNTACTICAL_EQUIVALENCE_FALSE", "answer=response_SAME_FORM_EXPONENTIAL"], {}), - ("0.48+0.88*I", "1*e^(0.5*I)", "answer=response", False, ["answer=response_FALSE", "answer=response_SAME_FORM_UNKNOWN"], {}), + ( + "2", + "2", + "response=answer", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "answer_WRITTEN_AS_NUMBER", + "response written as answer_TRUE", + "response=answer_TRUE", + "response=answer_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "4/2", + "2", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "answer_WRITTEN_AS_NUMBER", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2+x-x", + "2", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "answer_WRITTEN_AS_NUMBER", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_FALSE" + ], + {} + ), + ( + "2+2*I", + "2+2*I", + "answer=response", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2+2*I", + "2+2*I", + "answer=response", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {"I": {"aliases": ["i", "j"], "latex": r"\(i\)"}}), + ( + "2.00+2.00*I", + "2+2*I", + "answer=response", + True, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "3+3I", + "2+2*I", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_TRUE", + "answer=response_FALSE", + ], + {} + ), + ( + "2(1+I)", + "2+2*I", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2(1+I)", + "2+2*I", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {"I": {"aliases": ["i", "j"], "latex": r"\(i\)"}} + ), + ( + "2I+2", + "2+2*I", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "4/2+6/3*I", + "2+2*I", + "answer=response", + False, + [ + "answer_WRITTEN_AS_CARTESIAN", + "response written as answer_FALSE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2*e^(2*I)", + "2*e^(2*I)", + "answer=response", + True, + [ + "answer_WRITTEN_AS_EXPONENTIAL", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2*E^(2*I)", + "2*e^(2*I)", + "answer=response", + True, + [ + "answer_WRITTEN_AS_EXPONENTIAL", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2*exp(2*I)", + "2*e^(2*I)", + "answer=response", + True, + [ + "answer_WRITTEN_AS_EXPONENTIAL", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "2*e**(2*I)", + "2*e^(2*I)", + "answer=response", + True, + [ + "answer_WRITTEN_AS_EXPONENTIAL", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "e**(2*I)", + "1*e^(2*I)", + "answer=response", + True, + [ + "answer_WRITTEN_AS_EXPONENTIAL", + "response written as answer_TRUE", + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + ], + {} + ), + ( + "0.48+0.88*I", + "1*e^(0.5*I)", + "answer=response", + False, + [ + "answer_WRITTEN_AS_EXPONENTIAL", + "response written as answer_FALSE", + "answer=response_FALSE", + ], + {} + ), + ( + "(x-4)^2-5", + "(x-4)^2-5", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-5)^2-6", + "(x-4)^2-5", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-4)^2-5", + "(x-4)^2-5", + "answer=response", + True, + [ + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-4)^2 - 5", + "(x-4)^2-5", + "answer=response", + True, + [ + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "x^2-8x+11", + "(x-4)^2-5", + "answer=response", + False, + [ + "answer=response_TRUE", + "answer=response_SAME_SYMBOLS_TRUE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_FALSE" + ], + {"detailed_feedback": True} + ), + ( + "(x-3)^2-3", + "(x-4)^2-5", + "answer=response", + False, + [ + "answer=response_FALSE", + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x+4)^2-5", + "(x+(-4))^2-5", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x-4)^2+5", + "(x-4)^2+(-5)", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), + ( + "(x+4)^2+5", + "(x+(-4))^2+(-5)", + "response written as answer", + True, + [ + "answer_WRITTEN_AS_UNKNOWN", + "response written as answer_TRUE" + ], + {"detailed_feedback": True} + ), ] ) def test_syntactical_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): @@ -1241,7 +1581,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac @pytest.mark.parametrize( "response, answer, criteria, value, feedback_tags, additional_params", [ - ("14", "a+b*c", "response=answer where a=2; b=3; c=4", True, [], + ( + "14", + "a+b*c", + "response=answer where a=2; b=3; c=4", + True, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a\)'}, @@ -1252,7 +1597,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'rtol': 0, } ), - ("2/3", "a/b", "response=answer where a=2; b=3", True, [], + ( + "2/3", + "a/b", + "response=answer where a=2; b=3", + True, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a)'}, @@ -1262,7 +1612,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0.1, } ), - ("0.6667", "a/b", "response=answer where a=2; b=3", True, [], + ( + "0.6667", + "a/b", + "response=answer where a=2; b=3", + True, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a)'}, @@ -1272,7 +1627,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ("0.1667", "a/b", "response=answer where a=1; b=6", True, [], + ( + "0.1667", + "a/b", + "response=answer where a=1; b=6", + True, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a)'}, @@ -1282,7 +1642,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0.1, } ), - ("1.41", "sqrt(a)", "response=answer where a=2", True, [], + ( + "1.41", + "sqrt(a)", + "response=answer where a=2", + True, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a)'}, @@ -1291,7 +1656,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0.1, } ), - ("2", "(a/b)^c", "response=answer where a=7; b=5; c=1.4", False, [], + ( + "2", + "(a/b)^c", + "response=answer where a=7; b=5; c=1.4", + False, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a)'}, @@ -1302,7 +1672,12 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ("1.6017", "(a/b)^c", "response=answer where a=7; b=5; c=1.4", True, [], + ( + "1.6017", + "(a/b)^c", + "response=answer where a=7; b=5; c=1.4", + True, + [], { 'symbols': { 'a': {'aliases': [], 'latex': r'\(a)'}, @@ -1313,7 +1688,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ( # Exactly the same coefficients + ( # Exactly the same coefficients "0.02364x^3-0.2846x^2+1.383x-1.122", "0.02364x^3-0.2846x^2+1.383x-1.122", "response=answer where x=0, diff(response,x)=diff(answer,x) where x=0, diff(response,x,2)=diff(answer,x,2) where x=0, diff(response,x,3)=diff(answer,x,3) where x=0", @@ -1324,7 +1699,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ( # One less significant digit in response + ( # One less significant digit in response "0.0236x^3-0.285x^2+1.38x-1.12", "0.02364x^3-0.2846x^2+1.383x-1.122", "response=answer where x=0, diff(response,x)=diff(answer,x) where x=0, diff(response,x,2)=diff(answer,x,2) where x=0, diff(response,x,3)=diff(answer,x,3) where x=0", @@ -1335,7 +1710,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ( # Near lower bound for all coefficients + ( # Near lower bound for all coefficients "0.02355x^3-0.2845x^2+1.377x-1.117", "0.02364x^3-0.2846x^2+1.383x-1.122", "response=answer where x=0, diff(response,x)=diff(answer,x) where x=0, diff(response,x,2)=diff(answer,x,2) where x=0, diff(response,x,3)=diff(answer,x,3) where x=0", @@ -1346,7 +1721,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ( # Near upper bound for all coefficients + ( # Near upper bound for all coefficients "0.023649x^3-0.2849x^2+1.3849x-1.1249", "0.02364x^3-0.2846x^2+1.383x-1.122", "response=answer where x=0, diff(response,x)=diff(answer,x) where x=0, diff(response,x,2)=diff(answer,x,2) where x=0, diff(response,x,3)=diff(answer,x,3) where x=0", @@ -1357,7 +1732,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ( # Slightly below lower bound for all coefficients + ( # Slightly below lower bound for all coefficients "0.02352x^3-0.2831x^2+1.376x-1.1163", "0.02364x^3-0.2846x^2+1.383x-1.122", "response=answer where x=0, diff(response,x)=diff(answer,x) where x=0, diff(response,x,2)=diff(answer,x,2) where x=0, diff(response,x,3)=diff(answer,x,3) where x=0", @@ -1368,7 +1743,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), - ( # Slightly above upper bound for all coefficients + ( # Slightly above upper bound for all coefficients "0.023652x^3-0.2861x^2+1.390x-1.128", "0.02364x^3-0.2846x^2+1.383x-1.122", "response=answer where x=0, diff(response,x)=diff(answer,x) where x=0, diff(response,x,2)/2=diff(answer,x,2)/2 where x=0, diff(response,x,3)/6=diff(answer,x,3)/6 where x=0", @@ -1379,6 +1754,18 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac 'atol': 0, } ), + ( + "-10225", + "-2525", + "response = a*50 + d*51*25 where a=77; d=-5", + False, + [ + "response = a*50 + d*51*25 where a=77; d=-5_ONE_ADDITION_TO_SUBTRACTION", + 'response = a*50 + d*51*25 where a=77; d=-5_RESPONSE_CANDIDATES_ONE_ADDITION_TO_SUBTRACTION', + "response = a*50 + d*51*25 where a=77; d=-5_FALSE" + ], + {'detailed_feedback': True} + ), ] ) def test_criteria_where_numerical_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): @@ -1393,6 +1780,56 @@ def test_criteria_where_numerical_comparison(self, response, answer, criteria, v for feedback_tag in feedback_tags: assert feedback_tag in result["tags"] + @pytest.mark.parametrize( + "response, answer, criteria, value", + [ + ("1", "2", "response > answer", False), + ("2", "2", "response > answer", False), + ("3", "2", "response > answer", True), + ("1", "2", "response >= answer", False), + ("2", "2", "response >= answer", True), + ("3", "2", "response >= answer", True), + ("1", "2", "response < answer", True), + ("2", "2", "response < answer", False), + ("3", "2", "response < answer", False), + ("1", "2", "response <= answer", True), + ("2", "2", "response <= answer", True), + ("3", "2", "response <= answer", False), + ("5", "9", "response > 3", True), + ] + ) + def test_criteria_order_comparison(self, response, answer, criteria, value): + params = { + "strict_syntax": False, + "elementary_functions": True, + "criteria": criteria, + } + result = evaluation_function(response, answer, params) + assert result["is_correct"] is value + + @pytest.mark.parametrize( + "criteria, value", + [ + ("response contains c", False), + ("response contains a", True), + ("response contains x", True), + ("response contains pi", True), + ("response contains 23", True), + ("response contains 3", False), + ("response contains answer", True), + ] + ) + def test_contains_symbol(self, criteria, value): + response = "a+23+pi+sin(x)+y" + answer = "y" + params = { + "strict_syntax": False, + "elementary_functions": True, + "criteria": criteria, + } + result = evaluation_function(response, answer, params) + assert result["is_correct"] is value + @pytest.mark.parametrize( "response, answer, value", [ @@ -1428,33 +1865,21 @@ def test_exclamation_mark_for_factorial(self): result = evaluation_function(response, answer, params) assert result["is_correct"] is True - @pytest.mark.parametrize( - "response,value", - [ - ("k*alpha*(d^2 T)/(dx^2) = k*(dT/dt) - alpha*q_dot", True), - ("k*alpha*(d^2 T)/(dx^2) = k*(dT/dt) + alpha*q_dot", False), - ("d^2T/dx^2 + q_dot/k = 1/alpha*(dT/dt)", True), - ("d^2 T/dx^2 + q_dot/k = 1/alpha*(dT/dt)", True), - ("(d^2 T)/(dx^2) + q_dot/k = 1/alpha*(dT/dt)", True), - ("Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)", True), - ] - ) - def test_MECH50001_2_24_a(self, response, value): + def test_alternatives_to_input_symbols_takes_priority_over_elementary_function_alternatives(self): + answer = "Ef*exp(x)" params = { "strict_syntax": False, "elementary_functions": True, - "symbol_assumptions": "('alpha','constant') ('k','constant') ('T','function') ('q','function')", - 'symbols': { - 'alpha': {'aliases': [], 'latex': r'\alpha'}, - 'Derivative(q(x,t),t)': {'aliases': ['q_{dot}', 'q_dot'], 'latex': r'\dot{q}'}, - 'Derivative(T(x,t),t)': {'aliases': ['dT/dt'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}t}'}, - 'Derivative(T(x,t),x)': {'aliases': ['dT/dx'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}x}'}, - 'Derivative(T(x,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}'}, + "symbols": { + "Ef": {"aliases": ["E"], "latex": r"$E$"}, }, } - answer = "(d^2 T)/(dx^2) + q_dot/k = 1/alpha*(dT/dt)" + response = "E*e^x" result = evaluation_function(response, answer, params) - assert result["is_correct"] is value + assert result["is_correct"] is True + response = "e*e^x" + result = evaluation_function(response, answer, params) + assert result["is_correct"] is False if __name__ == "__main__": pytest.main(['-xk not slow', "--tb=line", '--durations=10', os.path.abspath(__file__)]) diff --git a/evaluation_function/symbolic_comparison_preview_tests.py b/app/tests/symbolic_preview_tests.py similarity index 85% rename from evaluation_function/symbolic_comparison_preview_tests.py rename to app/tests/symbolic_preview_tests.py index da4a61d..c6e6fcb 100644 --- a/evaluation_function/symbolic_comparison_preview_tests.py +++ b/app/tests/symbolic_preview_tests.py @@ -1,9 +1,9 @@ import os import pytest -from .preview_utilities import Params, extract_latex -from .symbolic_comparison_preview import preview_function -from .symbolic_comparison_evaluation_tests import elementary_function_test_cases +from ..utility.preview_utilities import Params, extract_latex +from ..preview_implementations.symbolic_preview import preview_function +from .symbolic_evaluation_tests import elementary_function_test_cases class TestPreviewFunction(): @@ -77,6 +77,44 @@ def test_sympy_with_equality_symbol(self): 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) diff --git a/evaluation_function/criteria_graph_utilities.py b/app/utility/criteria_graph_utilities.py similarity index 72% rename from evaluation_function/criteria_graph_utilities.py rename to app/utility/criteria_graph_utilities.py index 6064468..aa819cc 100644 --- a/evaluation_function/criteria_graph_utilities.py +++ b/app/utility/criteria_graph_utilities.py @@ -1,10 +1,11 @@ import json -evaluation_style = ("([","])") -starting_evaluation_style = (">","]") -criterion_style = ("[","]") -output_style = ("{{","}}") -special_style = ("[[","]]") +evaluation_style = ("([", "])") +starting_evaluation_style = (">", "]") +criterion_style = ("[", "]") +output_style = ("{{", "}}") +special_style = ("[[", "]]") + class CriteriaGraph: @@ -140,7 +141,7 @@ def as_dictionary(self): def json(self): return str(json.dumps(self.as_dictionary())) - def __init__(self, identifier, entry_evaluations = None): + def __init__(self, identifier, entry_evaluations=None): self.identifier = identifier self.evaluations = {} self.criteria = {} @@ -168,13 +169,13 @@ def json(self): } for (label, node) in self.criteria.items() }, "outputs": { - label: { + 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: sufficiency for (label, sufficiency) in self.sufficiencies.items() if sufficiency is not None}, + "sufficiencies": {label: list(suffs) for (label, suffs) in self.sufficiencies.items() if suffs is not None}, } return str(json.dumps(graph)) @@ -212,7 +213,7 @@ def add_criterion_node(self, label, summary, details, sufficiencies=None, evalua if label in self.criteria.keys(): raise Exception(f"Criterion node {label} is already defined.") if sufficiencies is not None: - raise Exception(f"Criterion nodes cannot have sufficiencies.") + raise Exception("Criterion nodes cannot have sufficiencies.") node = CriteriaGraph.Criterion(label, summary=summary, details=details, feedback_string_generator=feedback_string_generator) self.criteria.update({label: node}) self.sufficiencies.update({label: sufficiencies}) @@ -227,11 +228,11 @@ def add_output_node(self, label, summary, details): return node def add_node(self, node): - if isinstance(node,CriteriaGraph.Evaluation): + if isinstance(node, CriteriaGraph.Evaluation): self.add_evaluation_node(node.label, node.summary, node.details, node.sufficiencies) - elif isinstance(node,CriteriaGraph.Criterion): + elif isinstance(node, CriteriaGraph.Criterion): self.add_criterion_node(node.label, node.summary, node.details) - elif isinstance(node,CriteriaGraph.Output): + elif isinstance(node, CriteriaGraph.Output): self.add_output_node(node.label, node.summary, node.details) else: raise Exception("Can only add evaluation, criterion or output nodes to criteria graph.") @@ -283,46 +284,6 @@ def attach(self, source_label, target_label, summary=None, details=None, suffici target.incoming.append(edge) return -# def get_source_and_target(self, source_label, target_label): -# source = self.evaluations.get(source_label, None) -# if source is None: -# source = self.criteria.get(source_label, None) -# if source is None: -# source = self.outputs.get(source_label, None) -# if source is None: -# raise Exception(f"Unknown node {source_label}.") -# -# target = self.evaluations.get(target_label, None) -# if target is None: -# target = self.criteria.get(target_label, None) -# if target is None: -# target = self.outputs.get(target_label, None) -# if target is None: -# raise Exception(f"Unknown node {target_label}.") -# -# source_edge_index = None -# for (k, edge) in enumerate(source.outgoing): -# if edge.target.label == target_label: -# source_edge_index = k -# break -# if source_edge_index is None: -# raise Exception(f"No edge with source {source_label} has target {target_label}") -# -# target_edge_index = None -# for (k, edge) in enumerate(target.incoming): -# if edge.source.label == source_label: -# target_edge_index = k -# -# edge = source.outgoing[source_edge_index] -# -# return source, target, source_edge_index, target_edge_index -# -# def detach(self, source_label, target_label): -# source, target, source_edge_index, target_edge_index = self.get_source_and_target(source_label, target_label) -# source.outgoing = source.outgoing[0:source_edge_index]+source.outgoing[source_edge_index+1:] -# target.incoming = target.incoming[0:target_edge_index]+target.incoming[target_edge_index+1:] -# return - def add_sufficiencies(self, source_label, sufficiencies): if source_label in self.evaluations.keys(): if self.sufficiencies.get(source_label, None) is None: @@ -335,8 +296,8 @@ def add_sufficiencies(self, source_label, sufficiencies): return def starting_evaluations(self, label): - #TODO: Consider if starting evaluations should only accept evaluation nodes - # instead of guessing the intent when using criteria nodes as targets + # TODO: Consider if starting evaluations should only accept evaluation nodes + # instead of guessing the intent when using criteria nodes as targets if label in self.criteria.keys(): main_criteria = self.criteria[label] base_starting_evaluations = set(edge.source.label for edge in main_criteria.incoming) @@ -357,72 +318,6 @@ def starting_evaluations(self, label): starting_evaluations = base_starting_evaluations return starting_evaluations -# def connect(self, source_label, new_target_label): -# source = self.criteria.get(source_label, None) -# if source is None: -# raise Exception(f"Unknown criteria node {source_label}.") -# starting_evaluations = self.starting_evaluations(new_target_label) -# for evaluation_label in starting_evaluations: -# if evaluation_label not in [edge.target.label for edge in source.outgoing]: -# self.attach(source_label, evaluation_label) -# self.add_sufficiencies(evaluation_label, [source_label]) -# for edge in source.outgoing: -# if isinstance(edge.target, CriteriaGraph.Output): -# self.detach(source_label, edge.target.label) -# return -# -# def redirect(self, source_label, target_label, new_target_label, summary=None, details=None, sufficiencies=None): -# source, target, source_edge_index, target_edge_index = self.get_source_and_target(source_label, target_label) -# -# target.incoming = target.incoming[0:target_edge_index]+target.incoming[target_edge_index+1:] -# -# self.attach(source_label, new_target_label, summary=summary, details=details, sufficiencies=sufficiencies) -# new_edge = source.outgoing.pop() -# source.outgoing[source_edge_index].target = new_edge.target -# return - -# def join(self, graph): -# self_nodes = [self.evaluations, self.criteria, self.outputs] -# graph_nodes = [graph.evaluations, graph.criteria, graph.outputs] -# -# # Check which nodes are common to both graphs and -# # confirm that there are no conflicting node definitions -# common_nodes = [] -# for k in range(len(self_nodes)): -# for key in self_nodes[k].keys(): -# if key in graph_nodes[k].keys(): -# if self_nodes[k][key] == graph_nodes[k][key]: -# common_nodes.append(key) -# else: -# raise Exception(f"Node {key} does not match.") -# -# # Join graphs -# for k in range(len(graph_nodes)): -# for key in graph_nodes[k].keys(): -# if key in self_nodes[k].keys(): -# node = self_nodes[k][key] -# other_node = graph_nodes[k][key] -# for edge in other_node.incoming: -# if edge not in node.incoming: -# node.incoming.append(edge) -# for edge in other_node.outgoing: -# if edge not in node.outgoing: -# node.outgoing.append(edge) -# else: -# self_nodes[k].update({key: graph_nodes[k][key]}) -# -# # Add all depencies from joining graph -# for (evaluation_label, sufficiencies) in graph.sufficiencies.items(): -# if self.sufficiencies.get(evaluation_label, None) is None: -# self.sufficiencies.update({evaluation_label: []}) -# if sufficiencies is None: -# sufficiencies = [] -# for sufficiency in sufficiencies: -# if sufficiency not in self.sufficiencies[evaluation_label]: -# self.sufficiencies[evaluation_label].append(sufficiency) -# -# return - def build_tree(self, starting_evaluation, return_node=RETURN, main_criteria=None): node = self.evaluations.get(starting_evaluation, None) if node is None: @@ -451,7 +346,7 @@ def trees(self, label): def generate_feedback(self, response, main_criteria): evaluations = set().union(self.starting_evaluations(main_criteria)) visited_evaluations = set() - feedback = set() + feedback = dict() while len(evaluations) > 0: e = evaluations.pop() if e in self.evaluations.keys() and self.evaluations[e].replacement is not None: @@ -465,8 +360,8 @@ def generate_feedback(self, response, main_criteria): print(e) print(self.evaluations) raise exc - feedback = feedback.union(results) - for criterion in results: + feedback.update(results) + for criterion in results.keys(): labels = {edge.target.label for edge in self.criteria[criterion].outgoing} evaluations = evaluations.union(labels) - return feedback \ No newline at end of file + return feedback diff --git a/app/utility/criteria_parsing.py b/app/utility/criteria_parsing.py new file mode 100644 index 0000000..ae16af7 --- /dev/null +++ b/app/utility/criteria_parsing.py @@ -0,0 +1,88 @@ +from .slr_parsing_utilities import SLR_Parser, catch_undefined, infix, create_node, join, proceed, append_last + +start_symbol = "START" +end_symbol = "END" +null_symbol = "NULL" + +base_token_list = [ + (start_symbol, start_symbol), + (end_symbol, end_symbol), + (null_symbol, null_symbol), + (" *BOOL *", "BOOL"), + (" *EQUALITY *", "EQUALITY"), + (" *EQUAL *", "EQUAL"), + (" *EQUAL_LIST *", "EQUAL_LIST"), + (" *RESERVED *", "RESERVED"), + (" *= *", "EQUALITY"), + (" *(>=?|<=?|ORDER) *", "ORDER"), # less than (or equal), < (<=), greater than (or equal), > (>=) + (" *where *", "WHERE"), + (" *written +as *", "WRITTEN_AS"), + (" *contains *", "CONTAINS"), + (" *; *", "SEPARATOR"), + (" *OTHER *", "OTHER", catch_undefined), +] + +base_productions = [ + ("START", "BOOL", create_node), + ("BOOL", "EQUAL", proceed), + ("BOOL", "ORDER", proceed), + ("BOOL", "EQUAL where EQUAL", infix), + ("BOOL", "EQUAL where EQUAL_LIST", infix), + ("BOOL", "RESERVED written as OTHER", infix), + ("BOOL", "RESERVED written as RESERVED", infix), + ("BOOL", "RESERVED contains OTHER", infix), + ("BOOL", "RESERVED contains RESERVED", infix), + ("EQUAL_LIST", "EQUAL;EQUAL", infix), + ("EQUAL_LIST", "EQUAL_LIST;EQUAL", append_last), + ("EQUAL", "OTHER = OTHER", infix), + ("EQUAL", "RESERVED = OTHER", infix), + ("EQUAL", "OTHER = RESERVED", infix), + ("EQUAL", "RESERVED = RESERVED", infix), + ("EQUAL", "OTHER ORDER OTHER", infix), + ("EQUAL", "RESERVED ORDER OTHER", infix), + ("EQUAL", "OTHER ORDER RESERVED", infix), + ("EQUAL", "RESERVED ORDER RESERVED", infix), + ("OTHER", "RESERVED OTHER", join), + ("OTHER", "OTHER RESERVED", join), + ("OTHER", "OTHER OTHER", join), +] + + +def generate_criteria_parser(reserved_expressions, token_list=base_token_list, productions=base_productions): + + for value in reserved_expressions.values(): + token_list += [(key, "RESERVED") for key in value.keys()] + + return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) + + +if __name__ == "__main__": + test_criteria = [] + for comparison in ["=", ">", "<", ">=", "<="]: + test_criteria += [ + f"a {comparison} b", + f"response {comparison} b", + f"a {comparison} response", + f"response {comparison} answer", + ] + test_criteria += [ + "response = b*answer", + "response = q where q = a*b", + "response = q+p where q = a*b; p = b*c", + "response written as answer", + "response written as a*b*c", + "response - answer = 0", + ] + reserved_expressions = { + "learner": + {"response": "a*b*c", }, + "task": + {"answer": "c*b*a", } + } + criteria_parser = generate_criteria_parser(reserved_expressions) + for criteria in test_criteria: + tokens = criteria_parser.scan(criteria) + print(tokens) + tree = criteria_parser.parse(tokens) + print(tree) + print("---------------------------------------------------") diff --git a/evaluation_function/evaluation_response_utilities.py b/app/utility/evaluation_result_utilities.py similarity index 81% rename from evaluation_function/evaluation_response_utilities.py rename to app/utility/evaluation_result_utilities.py index 02d7d8d..dcfeae0 100644 --- a/evaluation_function/evaluation_response_utilities.py +++ b/app/utility/evaluation_result_utilities.py @@ -1,4 +1,4 @@ -class EvaluationResponse: +class EvaluationResult: def __init__(self): self.is_correct = False self.latex = None @@ -25,12 +25,14 @@ def add_feedback(self, feedback_item): raise TypeError("Feedback must be on the form (tag, feedback).") self._feedback_tags - def add_feedback_from_tags(self, tags, graph, inputs): - for tag in tags: + def add_feedback_from_tags(self, tags, graph): + for (tag, inputs) in tags.items(): if tag not in self._feedback_tags.keys(): - feedback_string = graph.criteria[tag].feedback_string_generator(inputs) - if feedback_string is not None: - self.add_feedback((tag, feedback_string)) + if inputs is None: + feedback_string = graph.criteria[tag].feedback_string_generator(dict()) + else: + feedback_string = graph.criteria[tag].feedback_string_generator(inputs) + self.add_feedback((tag, feedback_string)) def add_criteria_graph(self, name, graph): self._criteria_graphs.update({name: graph.json()}) @@ -40,7 +42,7 @@ def _serialise_feedback(self) -> str: for x in self._feedback: if (isinstance(x, tuple) and len(x[1].strip())) > 0: feedback.append(x[1].strip()) - elif len(x.strip()) > 0: + elif x is not None and len(x.strip()) > 0: feedback.append(x.strip()) return "
".join(feedback) diff --git a/evaluation_function/expression_utilities.py b/app/utility/expression_utilities.py similarity index 74% rename from evaluation_function/expression_utilities.py rename to app/utility/expression_utilities.py index e7bf5f2..27df6e6 100644 --- a/evaluation_function/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -1,3 +1,14 @@ +# Default parameters for expression handling +# Any contexts that use this collection of utility functions +# must define values for theses parameters +default_parameters = { + "complexNumbers": False, + "convention": "equal_precedence", + "elementary_functions": False, + "strict_syntax": True, + "multiple_answers_criteria": "all", +} + # -------- String Manipulation imports from .slr_parsing_utilities import ( SLR_expression_parser, @@ -5,17 +16,20 @@ group, compose ) - -from .feedback.symbolic_comparison import internal as symbolic_comparison_internal_messages +from .syntactical_comparison_utilities import is_number_regex # (Sympy) Expression Parsing imports from sympy.parsing.sympy_parser import parse_expr, split_symbols_custom, _token_splittable from sympy.parsing.sympy_parser import T as parser_transformations from sympy.printing.latex import LatexPrinter -from sympy import Basic, Symbol, Function, Equality +from sympy import Basic, Symbol, Equality, Function + import re from typing import Dict, List, TypedDict +from ..feedback.symbolic import feedback_generators as feedback_string_generators + + class ModifiedLatexPrinter(LatexPrinter): """Modified LatexPrinter class that prints logarithms other than the natural logarithm correctly. """ @@ -34,6 +48,7 @@ def _print_log(self, expr, exp=None): else: return tex + elementary_functions_names = [ ('sin', []), ('sinc', []), ('csc', ['cosec']), ('cos', []), ('sec', []), ('tan', []), ('cot', ['cotan']), ('asin', ['arcsin']), ('acsc', ['arccsc', 'arccosec', 'acosec']), ('acos', ['arccos']), ('asec', ['arcsec']), @@ -43,8 +58,10 @@ def _print_log(self, expr, exp=None): ('acsch', ['arccsch', 'arccosech']), ('asech', ['arcsech']), ('exp', ['Exp']), ('E', ['e']), ('log', ['ln']), ('sqrt', []), ('sign', []), ('Abs', ['abs']), ('Max', ['max']), ('Min', ['min']), ('arg', []), ('ceiling', ['ceil']), ('floor', []), + # Special symbols to make sure plus_minus and minus_plus are not destroyed during preprocessing + ('plus_minus', []), ('minus_plus', []), # Below this line should probably not be collected with elementary functions. Some like 'common operations' would be a better name - ('summation', ['sum','Sum']), ('Derivative', ['diff']), ('re', ['real']), ('im', ['imag']) + ('summation', ['sum', 'Sum']), ('Derivative', ['diff']), ('re', ['real']), ('im', ['imag']), ('conjugate', ['conj']) ] for data in elementary_functions_names: upper_case_alternatives = [data[0].upper()] @@ -55,7 +72,7 @@ def _print_log(self, expr, exp=None): greek_letters = [ "Alpha", "alpha", "Beta", "beta", "Gamma", "gamma", "Delta", "delta", "Epsilon", "epsilon", "Zeta", "zeta", - "Eta", "eta", "Theta", "theta", "Iota", "iota", "Kappa", "kappa", "Lambda", # "lambda" removed to avoid collision with reserved keyword in python + "Eta", "eta", "Theta", "theta", "Iota", "iota", "Kappa", "kappa", "Lambda", # "lambda" removed to avoid collision with reserved keyword in python "Mu", "mu", "Nu", "nu", "Xi", "xi", "Omicron", "omicron", "Pi", "pi", "Rho", "rho", "Sigma", "sigma", "Tau", "tau", "Upsilon", "upsilon", "Phi", "phi", "Chi", "chi", "Psi", "psi", "Omega", "omega" @@ -64,19 +81,29 @@ def _print_log(self, expr, exp=None): # -------- String Manipulation Utilities -def create_expression_set(expr, params): +def create_expression_set(exprs, params): + if isinstance(exprs, str): + if exprs.startswith('{') and exprs.endswith('}'): + exprs = [expr.strip() for expr in exprs[1:-1].split(',')] + else: + exprs = [exprs] expr_set = set() - if "plus_minus" in params.keys(): - expr = expr.replace(params["plus_minus"], "plus_minus") - - if "minus_plus" in params.keys(): - expr = expr.replace(params["minus_plus"], "minus_plus") - - if ("plus_minus" in expr) or ("minus_plus" in expr): - expr_set.add(expr.replace("plus_minus", "+").replace("minus_plus", "-")) - expr_set.add(expr.replace("plus_minus", "-").replace("minus_plus", "+")) - else: - expr_set.add(expr) + for expr in exprs: + expr = substitute_input_symbols(expr, params)[0] + if "plus_minus" in params.keys(): + expr = expr.replace(params["plus_minus"], "plus_minus") + + if "minus_plus" in params.keys(): + expr = expr.replace(params["minus_plus"], "minus_plus") + + if ("plus_minus" in expr) or ("minus_plus" in expr): + for pm_mp_ops in [("+", "-"), ("-", "+")]: + expr_string = expr.replace("plus_minus", pm_mp_ops[0]).replace("minus_plus", pm_mp_ops[1]).strip() + while expr_string[0] == "+": + expr_string = expr_string[1:] + expr_set.add(expr_string.strip()) + else: + expr_set.add(expr) return list(expr_set) @@ -166,7 +193,7 @@ def convert_absolute_notation(expr, name): ambiguity_tag = "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" remark = "" if n_expr > 2 and len(expr_ambiguous_abs_pos) > 0: - remark = symbolic_comparison_internal_messages[ambiguity_tag](name) + remark = feedback_string_generators["INTERNAL"](ambiguity_tag)({'name': name}) feedback = None if len(remark) > 0: @@ -206,6 +233,17 @@ def preprocess_according_to_chosen_convention(expression, parameters): return expression +def protect_elementary_functions_substitutions(expr): + alias_substitutions = [] + for (name, alias_list) in elementary_functions_names+special_symbols_names: + if name in expr: + alias_substitutions += [(name, " "+name)] + for alias in alias_list: + if alias in expr: + alias_substitutions += [(alias, " "+name)] + return alias_substitutions + + def substitute_input_symbols(exprs, params): ''' Input: @@ -220,21 +258,35 @@ def substitute_input_symbols(exprs, params): if isinstance(exprs, str): exprs = [exprs] - substitutions = [(expr, expr) for expr in params.get("reserved_keywords",[])] + substitutions = [(expr, expr) for expr in params.get("reserved_keywords", [])] + substitutions += [(expr, expr) for expr in params.get("unsplittable_symbols", [])] + + if "plus_minus" in params.keys(): + substitutions += [(params["plus_minus"], "plus_minus")] + + if "minus_plus" in params.keys(): + substitutions += [(params["minus_plus"], "minus_plus")] + + input_symbols = params.get("symbols",dict()) + + input_symbols_alternatives = [] + for (code, definition) in input_symbols.items(): + input_symbols_alternatives += definition["aliases"] if params.get("elementary_functions", False) is True: alias_substitutions = [] for expr in exprs: for (name, alias_list) in elementary_functions_names+special_symbols_names: - if name in expr: - alias_substitutions += [(name, " "+name)] - for alias in alias_list: - if alias in expr: - alias_substitutions += [(alias, " "+name)] + if name in input_symbols_alternatives: + continue + else: + if (name in expr) and not (name in input_symbols_alternatives): + alias_substitutions += [(name, " "+name)] + for alias in alias_list: + if (alias in expr) and not (alias in input_symbols_alternatives): + alias_substitutions += [(alias, " "+name)] substitutions += alias_substitutions - input_symbols = params.get("symbols",dict()) - if "symbols" in params.keys(): # Removing invalid input symbols input_symbols_to_remove = [] @@ -299,17 +351,23 @@ def substitute_input_symbols(exprs, params): if len(alternative) > 0: substitutions.append((alternative, input_symbol[0])) + # Since 'lambda' is a reserved keyword in python + # we need to make sure it is not substituted back in + substitutions = [(original, subs.replace("lambda", "lamda")) for (original, subs) in substitutions] + + substitutions = list(set(substitutions)) if len(substitutions) > 0: - substitutions.sort(key=lambda x: -len(x[0])) + substitutions.sort(key=substitutions_sort_key) for k in range(0, len(exprs)): exprs[k] = substitute(exprs[k], substitutions) + exprs[k] = " ".join(exprs[k].split()) return exprs def find_matching_parenthesis(string, index, delimiters=None): depth = 0 - if delimiters == None: + if delimiters is None: delimiters = ('(', ')') for k in range(index, len(string)): if string[k] == delimiters[0]: @@ -395,23 +453,22 @@ def substitute(string, substitutions): def compute_relative_tolerance_from_significant_decimals(string): rtol = None string = string.strip() - separators = "e*^ " - separator_indices = [] - for separator in separators: - if separator in string: - separator_indices.append(string.index(separator)) - else: - separator_indices.append(len(string)) - index = min(separator_indices) - significant_characters = string[0:index].replace(".", "") - index = 0 - for c in significant_characters: - if c in "-0": - index += 1 - else: - break - significant_characters = significant_characters[index:] - rtol = 5*10**(-len(significant_characters)) + if re.fullmatch(is_number_regex, string) is None: + rtol = 0 + else: + if "e" in string.casefold(): + string = "".join(string.split()) + separators = "e*^ " + separator_indices = [] + for separator in separators: + if separator in string: + separator_indices.append(string.index(separator)) + else: + separator_indices.append(len(string)) + index = min(separator_indices) + significant_characters = string[0:index].replace(".", "") + significant_characters = significant_characters.lstrip("-0") + rtol = 5*10**(-len(significant_characters)) return rtol @@ -508,7 +565,7 @@ def create_sympy_parsing_params(params, unsplittable_symbols=tuple(), symbol_ass parse_expression function. ''' - unsplittable_symbols = list(unsplittable_symbols) + unsplittable_symbols = list(unsplittable_symbols)+params.get("reserved_keywords", []) if "symbols" in params.keys(): for symbol in params["symbols"].keys(): if len(symbol) > 1: @@ -520,13 +577,12 @@ def create_sympy_parsing_params(params, unsplittable_symbols=tuple(), symbol_ass beta = Symbol("beta") gamma = Symbol("gamma") zeta = Symbol("zeta") - if params.get("complexNumbers", False) is True: + if params["complexNumbers"] is True: from sympy import I else: I = Symbol("I") - if params.get("elementary_functions", False) is True: + if params["elementary_functions"] is True: from sympy import E - e = E else: E = Symbol("E") N = Symbol("N") @@ -554,10 +610,12 @@ def create_sympy_parsing_params(params, unsplittable_symbols=tuple(), symbol_ass "strict_syntax": strict_syntax, "symbol_dict": symbol_dict, "extra_transformations": tuple(), - "elementary_functions": params.get("elementary_functions", False), - "convention": params.get("convention", None), + "elementary_functions": params["elementary_functions"], + "convention": params["convention"], "simplify": params.get("simplify", False), - "constants": set() + "rationalise": params.get("rationalise", True), + "constants": set(), + "complexNumbers": params["complexNumbers"], } symbol_assumptions = list(symbol_assumptions) @@ -586,7 +644,21 @@ def create_sympy_parsing_params(params, unsplittable_symbols=tuple(), symbol_ass return parsing_params -def parse_expression(expr, parsing_params): +def substitutions_sort_key(x): + return -len(x[0])-len(x[1])/(10**(1+len(str(len(x[1]))))) + + +def preprocess_expression(name, expr, parameters): + expr = substitute_input_symbols(expr.strip(), parameters) + expr = expr[0] + expr, abs_feedback = convert_absolute_notation(expr, name) + success = True + if abs_feedback is not None: + success = False + return success, expr, abs_feedback + + +def parse_expression(expr_string, parsing_params): ''' Input: expr : string to be parsed into a sympy expression @@ -596,39 +668,47 @@ def parse_expression(expr, parsing_params): to the parameters in parsing_params ''' - expr = preprocess_according_to_chosen_convention(expr, parsing_params) + expr_set = create_expression_set(expr_string, parsing_params) strict_syntax = parsing_params.get("strict_syntax", False) extra_transformations = parsing_params.get("extra_transformations", ()) unsplittable_symbols = parsing_params.get("unsplittable_symbols", ()) symbol_dict = parsing_params.get("symbol_dict", {}) separate_unsplittable_symbols = [(x, " "+x+" ") for x in unsplittable_symbols] - # new approach substitutions = separate_unsplittable_symbols - if parsing_params.get("elementary_functions", False) is True: - alias_substitutions = [] - for (name, alias_list) in elementary_functions_names+special_symbols_names: - if name in expr: - alias_substitutions += [(name, " "+name)] - for alias in alias_list: - if alias in expr: - alias_substitutions += [(alias, " "+name)] - substitutions += alias_substitutions - substitutions.sort(key=lambda x: -len(x[0])) - expr = substitute(expr, substitutions) - can_split = lambda x: False if x in unsplittable_symbols else _token_splittable(x) - if strict_syntax is True: - transformations = parser_transformations[0:4]+extra_transformations - else: - transformations = parser_transformations[0:5, 6]+extra_transformations+(split_symbols_custom(can_split),)+parser_transformations[8] - if parsing_params.get("rationalise", False): - transformations += parser_transformations[11] - if parsing_params.get("simplify", False): - parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict) - if not isinstance(parsed_expr,Equality): - parsed_expr = parsed_expr.simplify() + + parsed_expr_set = set() + for expr in expr_set: + expr = preprocess_according_to_chosen_convention(expr, parsing_params) + if parsing_params["elementary_functions"] is True: + substitutions += protect_elementary_functions_substitutions(expr) + substitutions = list(set(substitutions)) + substitutions.sort(key=substitutions_sort_key) + expr = substitute(expr, substitutions) + expr = " ".join(expr.split()) + can_split = lambda x: False if x in unsplittable_symbols else _token_splittable(x) + if strict_syntax is True: + transformations = parser_transformations[0:4]+extra_transformations + else: + transformations = parser_transformations[0:5, 6]+extra_transformations+(split_symbols_custom(can_split),)+parser_transformations[8, 9] + if parsing_params.get("rationalise", False): + transformations += parser_transformations[11] + if "=" in expr: + expr_parts = expr.split("=") + lhs = parse_expr(expr_parts[0], transformations=transformations, local_dict=symbol_dict) + rhs = parse_expr(expr_parts[1], transformations=transformations, local_dict=symbol_dict) + parsed_expr = Equality(lhs, rhs, evaluate=False) + elif parsing_params.get("simplify", False): + parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict) + if not isinstance(parsed_expr, Equality): + parsed_expr = parsed_expr.simplify() + else: + parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict, evaluate=False) + if not isinstance(parsed_expr, Basic): + raise ValueError(f"Failed to parse Sympy expression `{expr}`") + parsed_expr_set.add(parsed_expr) + + if len(expr_set) == 1: + return parsed_expr_set.pop() else: - parsed_expr = parse_expr(expr, transformations=transformations, local_dict=symbol_dict, evaluate=False) - if not isinstance(parsed_expr, Basic): - raise ValueError(f"Failed to parse Sympy expression `{expr}`") - return parsed_expr + return parsed_expr_set diff --git a/evaluation_function/slr_quantity.py b/app/utility/physical_quantity_utilities.py similarity index 57% rename from evaluation_function/slr_quantity.py rename to app/utility/physical_quantity_utilities.py index cfa5031..b902bed 100644 --- a/evaluation_function/slr_quantity.py +++ b/app/utility/physical_quantity_utilities.py @@ -1,47 +1,59 @@ -# ------- -# IMPORTS -# ------- - import re from enum import Enum -from .criteria_utilities import Criterion -from .expression_utilities import parse_expression, compute_relative_tolerance_from_significant_decimals, create_sympy_parsing_params -from .symbolic_comparison_evaluation import evaluation_function as symbolic_comparison -from .symbolic_comparison_preview import preview_function as symbolic_preview -from .feedback.quantity_comparison import criteria as physical_quantities_criteria -from .feedback.quantity_comparison import internal as physical_quantities_messages -from .feedback.quantity_comparison import answer_matches_response_graph -from .expression_utilities import substitute -from .slr_parsing_utilities import SLR_Parser, relabel, catch_undefined, infix, insert_infix, group, tag_removal, create_node, ExprNode, operate -from sympy import Basic, posify +from .expression_utilities import ( + substitute, + create_sympy_parsing_params, + parse_expression +) +from .slr_parsing_utilities import ( + SLR_Parser, + relabel, + catch_undefined, + infix, + insert_infix, + group, + tag_removal, + create_node, + ExprNode +) from .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, conversion_to_base_si_units +from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from .criteria_utilities import traverse - -# ----------------- -# QUANTITY HANDLING -# ----------------- +from ..preview_implementations.symbolic_preview import preview_function as symbolic_preview QuantityTags = Enum("QuantityTags", {v: i for i, v in enumerate("UVNR", 1)}) +units_sets_dictionary = { + "SI": set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units, + "common": 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, + "imperial": set_of_imperial_units, +} + + class PhysicalQuantity: def __init__(self, name, parameters, ast_root, parser, messages=None, tag_handler=lambda x: x): self.name = name self.parameters = parameters - prefixes = [x[0] for x in set_of_SI_prefixes] - fundamental_units = [x[0] for x in set_of_SI_base_unit_dimensions] - dimensions = [x[2] for x in set_of_SI_base_unit_dimensions] - unsplittable_symbols = prefixes+fundamental_units+dimensions - symbol_assumptions = tuple([(f'{s}','positive') for s in fundamental_units+dimensions]) + prefixes = set(x[0] for x in set_of_SI_prefixes) + fundamental_units = set(x[0] for x in set_of_SI_base_unit_dimensions) + units_string = parameters.get("units_string", "SI common imperial") + valid_units = set() + for key in units_sets_dictionary.keys(): + if key in units_string: + for unit in units_sets_dictionary[key]: + valid_units = valid_units.union(set((unit[0],))) + dimensions = set(x[2] for x in set_of_SI_base_unit_dimensions) + unsplittable_symbols = list(prefixes | fundamental_units | valid_units | dimensions) + symbol_assumptions = tuple((f'{s}', 'positive') for s in unsplittable_symbols) self.parsing_params = create_sympy_parsing_params( parameters, unsplittable_symbols=unsplittable_symbols, symbol_assumptions=symbol_assumptions, ) - if messages == None: + if messages is None: self.messages = [] else: self.messages = messages @@ -65,7 +77,18 @@ def revert_content(node): if node.label != "GROUP": node.content = node.original[node.start:node.end+1] if node.label == "UNIT" or QuantityTags.U in node.tags: - self.messages += [("REVERTED_UNIT", physical_quantities_messages["REVERTED_UNIT"](node.original[:node.start], node.content_string(), node.original[node.end+1:]))] + self.messages += [ + ( + name+"_REVERTED_UNIT_"+str(len(self.messages)), + physical_quantity_feedback_string_generators["INTERNAL"]("REVERTED_UNIT")( + { + 'before': node.original[:node.start], + 'marked': node.content_string(), + 'after': node.original[node.end+1:] + } + ) + ) + ] return ["", ""] self.value.traverse(revert_content) self.value_latex_string = self._value_latex(parameters) @@ -132,6 +155,17 @@ def _rotate_until_root_is_split(self): def _value_latex(self, parameters): if self.value is not None: preview_parameters = {**parameters} + prefixes = set(x[0] for x in set_of_SI_prefixes) + fundamental_units = set(x[0] for x in set_of_SI_base_unit_dimensions) + units_string = parameters.get("units_string", "SI common imperial") + valid_units = set() + for key in units_sets_dictionary.keys(): + if key in units_string: + for unit in units_sets_dictionary[key]: + valid_units = valid_units.union(set((unit[0], unit[1])+unit[3]+unit[4])) + dimensions = set(x[2] for x in set_of_SI_base_unit_dimensions) + unsplittable_symbols = list(prefixes | fundamental_units | valid_units | dimensions) + preview_parameters.update({"reserved_keywords": preview_parameters.get("reserved_keywords", [])+unsplittable_symbols}) if "rtol" not in preview_parameters.keys(): preview_parameters.update({"rtol": 1e-12}) original_string = self.value.original_string() @@ -183,7 +217,7 @@ def _all_forms(self): expanded_unit = self._expand_units(converted_unit) converted_unit_string = expanded_unit.content_string() try: - expanded_unit = parse_expression(expanded_unit.content_string(), parsing_params) + expanded_unit = parse_expression(converted_unit_string, parsing_params) converted_unit = expanded_unit except Exception as e: raise Exception("SymPy was unable to parse the "+self.name+" unit") from e @@ -193,20 +227,17 @@ def _all_forms(self): converted_unit_factor = converted_unit.subs(substitution_dict).simplify() converted_unit = (converted_unit/converted_unit_factor).simplify(rational=True) converted_value = "("+str(converted_value)+")*("+str(converted_unit_factor)+")" - converted_unit_string = str(converted_unit) converted_dimension = substitute(converted_unit_string, base_unit_dimensions) converted_dimension = parse_expression(converted_dimension, parsing_params) + if converted_value is not None: + converted_value = parse_expression(converted_value, parsing_params) return converted_value, converted_unit, expanded_unit, converted_dimension - def SLR_generate_unit_dictionaries(units_string, strictness): - units_sets_dictionary = { - "SI": set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units, - "common": 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, - "imperial": set_of_imperial_units, - } + if strictness == "legacy": + strictness = "natural" units_tuples = set() for key in units_sets_dictionary.keys(): @@ -271,7 +302,7 @@ def tag_handler(node): elif node.label == "SOLIDUS" and node.children[0].content == "1" and node.children[1].tags == {QuantityTags.U}: tags.remove(QuantityTags.N) elif node.label == "SOLIDUS" and node.children[0].tags == {QuantityTags.N} and node.children[1].tags == {QuantityTags.N}: - tags = tags # Do not change tags + tags = tags # Do not change tags elif node.label in ["PRODUCT", "SOLIDUS", "POWER"]: if any(x in tags for x in [QuantityTags.N, QuantityTags.V, QuantityTags.R]): if QuantityTags.U in tags: @@ -300,6 +331,8 @@ def tag_handler(node): def SLR_quantity_parser(parameters): units_string = parameters.get("units_string", "SI common imperial") strictness = parameters.get("strictness", "natural") + if strictness == "legacy": + strictness = "natural" units_dictionary, prefixed_units_dictionary, units_end_dictionary, prefixed_units_end_dictionary = \ SLR_generate_unit_dictionaries(units_string, strictness) max_unit_name_length = max(len(x) for x in [units_dictionary.keys()]+[units_end_dictionary.keys()]) @@ -394,7 +427,6 @@ def juxtaposition_natural(production, output, tag_handler): else: for k, elem in enumerate(output[-2:], -2): if is_unit[k] is True: -# elem.tags.remove(QuantityTags.U) elem.tags.add(QuantityTags.V) return group(2, empty=True)(production, output, tag_handler) juxtaposition = juxtaposition_natural @@ -444,7 +476,6 @@ def error_action_infix_missing_argument(p, s, a, i, t, o): def SLR_quantity_parsing(expr, parameters, parser, name): - expr = expr.strip() tokens = parser.scan(expr) @@ -455,248 +486,3 @@ def SLR_quantity_parsing(expr, parameters, parser, name): tag_handler = set_tags(parameters.get("strictness", "strict")) return PhysicalQuantity(name, parameters, quantity[0], parser, messages=[], tag_handler=tag_handler) - - -def quantity_comparison(response, answer, parameters, eval_response): - - eval_response.is_correct = False - - quantity_parser = SLR_quantity_parser(parameters) - quantity_parsing = SLR_quantity_parsing - - # ----------------- - # CRITERIA HANDLING - # ----------------- - - quantities = dict() - evaluated_criteria = dict() - criteria = physical_quantities_criteria - parameters = dict(parameters) - input_criteria_string = parameters.pop("criteria", "") - input_criteria = {"INPUT_CRITERIA_"+str(k): Criterion(crit) for (k, crit) in enumerate(input_criteria_string.split("."))} - criteria.update(input_criteria) - prefixes = [x[0] for x in set_of_SI_prefixes] - fundamental_units = [x[0] for x in set_of_SI_base_unit_dimensions] - dimensions = [x[2] for x in set_of_SI_base_unit_dimensions] - unsplittable_symbols = prefixes+fundamental_units+dimensions - symbol_assumptions = tuple([(f'{s}','positive') for s in fundamental_units+dimensions]) - parsing_params = create_sympy_parsing_params(parameters, unsplittable_symbols=unsplittable_symbols, symbol_assumptions=symbol_assumptions) - - def check_criterion(tag, arg_names=None): - if arg_names is None: - collect_args = True - args = [] - else: - collect_args = False - args = tuple(quantities[name] for name in arg_names) - if (tag, arg_names) in evaluated_criteria.keys(): - result = evaluated_criteria[(tag, arg_names)][0] - else: - criterion = criteria[tag] - if criterion.check == None: - result = True - else: - criterion_tokens = criteria_parser.scan(criterion.check) - number_of_args = 0 - for token in criterion_tokens: - if token.label == "QUANTITY": - if collect_args is True: - token.content = quantities[token.content.strip()] - args.append(token.content) - else: - token.content = args[number_of_args] - number_of_args += 1 - criterion_parsed = criteria_parser.parse(criterion_tokens)[0] - - def execute(node): - key = node.label.strip() - if key in criteria_operations.keys(): - executed_children = [execute(c) for c in node.children] - return criteria_operations[key](executed_children) - elif key == "QUANTITY" or key == "INPUT": - return node.content - elif "INPUT" in [c.label for c in node.children]: - executed_children = [execute(c) for c in node.children] - expression = "".join([str(c) for c in executed_children]) - expression = parse_expression(expression, parsing_params) - return expression - return None - result = execute(criterion_parsed) - feedback = criteria[tag][result](args) - evaluated_criteria.update({(tag, arg_names): (result, feedback)}) - return result - - def matches(inputs): - if isinstance(inputs[0], PhysicalQuantity) and isinstance(inputs[1], PhysicalQuantity): - value0 = inputs[0].standard_value - unit0 = inputs[0].standard_unit - value1 = inputs[1].standard_value - unit1 = inputs[1].standard_unit - value_match = False - unit_match = False - if value0 is None and value1 is None: - value_match = True - elif value0 is not None and value1 is not None: - value_match = symbolic_comparison(value0, value1, parameters)["is_correct"] - if unit0 is None and unit0 is None: - unit_match = True - elif unit0 is not None and unit1 is not None: - unit_match = bool((unit0 - unit1).simplify() == 0) - return value_match and unit_match - elif isinstance(inputs[0], Basic) and isinstance(inputs[1], Basic): - if inputs[0] is not None and inputs[1] is not None: - dimension_match = bool((inputs[0] - inputs[1]).cancel().simplify().simplify() == 0) # TODO: Make separate function for checking equality of expressions that can be parsed - else: - dimension_match = False - return dimension_match - return False - - def compare(comparison): - comparison_dict = { - "=": lambda inputs: bool((inputs[0] - inputs[1]).cancel().simplify().simplify() == 0), - "<=": lambda inputs: bool((inputs[0] - inputs[1]).cancel().simplify().simplify() <= 0), - ">=": lambda inputs: bool((inputs[0] - inputs[1]).cancel().simplify().simplify() >= 0), - "<": lambda inputs: bool((inputs[0] - inputs[1]).cancel().simplify().simplify() < 0), - ">": lambda inputs: bool((inputs[0] - inputs[1]).cancel().simplify().simplify() > 0), - } - def wrap(inputs): - if inputs[0] is not None and inputs[1] is not None: - return comparison_dict[comparison](inputs) - else: - return False - return wrap - - criteria_operations = { - "and": lambda x: x[0] and x[1], - "not": lambda x: not x[0], - "has": lambda x: x[0] is not None, - "unit": lambda quantity: quantity[0].unit, - "expanded_unit": lambda quantity: quantity[0].expanded_unit, - "base_unit": lambda quantity: quantity[0].standard_unit, - "value": lambda quantity: quantity[0].value, - "is_number": lambda value: value[0] is not None and value[0].tags == {QuantityTags.N}, - "is_expression": lambda value: value[0] is not None and QuantityTags.V in value[0].tags, - "matches": matches, - "dimension": lambda quantity: quantity[0].dimension, - "=": compare("="), - "<=": compare("<="), - ">=": compare(">="), - "<": compare("<"), - ">": compare(">"), - } - - def generate_criteria_parser(): - start_symbol = "START" - end_symbol = "END" - null_symbol = "NULL" - - token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" *BOOL *", "BOOL"), - (" *UNIT *", "UNIT"), - (" *VALUE *", "VALUE"), - (" *QUANTITY *", "QUANTITY"), - (" *DIMENSION *", "DIMENSION"), - ("\( *", "START_DELIMITER"), - (" *\)", "END_DELIMITER"), - ("response", "QUANTITY"), - ("answer", "QUANTITY"), - ("INPUT", "INPUT", catch_undefined), - ] - token_list += [(" *"+x+" *", " "+x+" ") for x in criteria_operations.keys()] - - productions = [ - ("START", "BOOL", create_node), - ("BOOL", "BOOL and BOOL", infix), - ("BOOL", "UNIT matches UNIT", infix), - ("BOOL", "VALUE matches VALUE", infix), - ("BOOL", "QUANTITY matches QUANTITY", infix), - ("BOOL", "DIMENSION matches DIMENSION", infix), - ("BOOL", "not(BOOL)", operate(1)), - ("BOOL", "has(UNIT)", operate(1)), - ("BOOL", "has(VALUE)", operate(1)), - ("BOOL", "is_number(VALUE)", operate(1)), - ("BOOL", "is_expression(VALUE)", operate(1)), - ("BOOL", "UNIT=UNIT", infix), - ("BOOL", "UNIT<=UNIT", infix), - ("BOOL", "UNIT>=UNIT", infix), - ("BOOL", "UNITUNIT", infix), - ("UNIT", "unit(QUANTITY)", operate(1)), - ("UNIT", "base_unit(QUANTITY)", operate(1)), - ("UNIT", "expanded_unit(QUANTITY)", operate(1)), - ("UNIT", "INPUT UNIT", group(2, empty=True)), - ("UNIT", "UNIT INPUT", group(2, empty=True)), - ("VALUE", "value(QUANTITY)", operate(1)), - ("QUANTITY", "INPUT", create_node), - ("DIMENSION", "dimension(QUANTITY)", operate(1)), - ] - - return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) - - criteria_parser = generate_criteria_parser() - - # ------------------- - # QUANTITY COMPARISON - # ------------------- - - for criterion in criteria.values(): - if criterion.check is None: - continue - criterion_tokens = criteria_parser.scan(criterion.check) - relevant_quantities = set() - relevant_criteria_operations = set() - for token in criterion_tokens: - if token.label == "QUANTITY": - content = token.content.strip() - if content != "QUANTITY": - relevant_quantities.add(token.content.strip()) - if content not in quantities.keys(): - if content == "answer": - try: - ans_parsed = quantity_parsing(answer, parameters, quantity_parser, "answer") - except Exception as e: - raise Exception("Could not parse quantity expression in answer: "+str(e)) from e - quantities.update({"answer": ans_parsed}) - elif content == "response": - try: - res_parsed = quantity_parsing(response, parameters, quantity_parser, "response") - except Exception as e: - eval_response.add_feedback(("PARSE_EXCEPTION", str(e))) - eval_response.is_correct = False - return eval_response - quantities.update({"response": res_parsed}) - else: - relevant_quantities.update({token.content.strip(): token.content.strip()}) - elif token.label.strip() in criteria_operations.keys(): - relevant_criteria_operations.add(token.label.strip()) - - # Collects messages from parsing the response, these needs to be returned as feedback later - if "response" in quantities.keys(): - for message in quantities["response"].messages: - eval_response.add_feedback(message) - - # Computes the desired tolerance used for numerical computations based on the formatting of the answer - if check_criterion("NUMBER_VALUE", ("answer",)): - parameters["rtol"] = parameters.get( - "rtol", - compute_relative_tolerance_from_significant_decimals( - quantities["answer"].value.content_string() - ), - ) - - eval_response.latex = quantities["response"].latex_string - - check = lambda label, criterion: check_criterion(label, arg_names=("response", "answer")) - - #is_correct = answer_matches_response_graph.traverse(check) - is_correct = traverse(answer_matches_response_graph,check) - eval_response.is_correct = is_correct - - for (tag, result) in evaluated_criteria.items(): - if len(result[1].strip()) > 0: - eval_response.add_feedback((tag[0], "- "+result[1]+"
")) - - return eval_response diff --git a/app/utility/preview_utilities.py b/app/utility/preview_utilities.py new file mode 100644 index 0000000..6646dda --- /dev/null +++ b/app/utility/preview_utilities.py @@ -0,0 +1,135 @@ +from typing import TypedDict +from typing_extensions import NotRequired + +from sympy import Symbol +from latex2sympy2 import latex2sympy + +from copy import deepcopy + +from .expression_utilities import ( + default_parameters, + extract_latex, + SymbolDict, + find_matching_parenthesis, + create_expression_set, +) + + +class Params(TypedDict): + is_latex: bool + simplify: NotRequired[bool] + symbols: NotRequired[SymbolDict] + + +class Preview(TypedDict): + latex: str + sympy: str + feedback: str + + +class Result(TypedDict): + preview: Preview + + +def parse_latex(response: str, symbols: SymbolDict, simplify: bool, parameters=None) -> str: + """Parse a LaTeX string to a sympy string while preserving custom symbols. + + Args: + response (str): The LaTeX expression to parse. + symbols (SymbolDict): A mapping of sympy symbol strings and LaTeX + symbol strings. + simplify (bool): If set to false the preview will attempt to preserve + the way that the response was written as much as possible. If set + to True the response will be simplified before the preview string + is generated. + parameters (dict): parameters used when generating sympy output when + the response is written in LaTeX + + Raises: + ValueError: If the LaTeX string or symbol couldn't be parsed. + + Returns: + str: The expression in sympy syntax. + """ + if parameters is not None: + for (key, value) in default_parameters.items(): + if key not in parameters.keys(): + parameters.update({key: value}) + else: + parameters = deepcopy(default_parameters) + + substitutions = {} + + pm_placeholder = None + mp_placeholder = None + + if r"\pm " in response or r"\mp " in response: + response_set = set() + for char in 'abcdefghjkoqrtvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ': + if char not in response and pm_placeholder is None: + pm_placeholder = char + substitutions[pm_placeholder] = Symbol(pm_placeholder, commutative=False) + elif char not in response and mp_placeholder is None: + mp_placeholder = char + substitutions[mp_placeholder] = Symbol(mp_placeholder, commutative=False) + if pm_placeholder is not None and mp_placeholder is not None: + break + for expr in create_expression_set(response.replace(r"\pm ", 'plus_minus').replace(r"\mp ", 'minus_plus'), parameters): + response_set.add(expr) + response = response_set + else: + response_set = {response} + + for sympy_symbol_str in symbols: + symbol_str = symbols[sympy_symbol_str]["latex"] + latex_symbol_str = extract_latex(symbol_str) + + if "\pm" not in symbol_str and "\mp" not in symbol_str: + try: + latex_symbol = latex2sympy(latex_symbol_str) + except Exception: + raise ValueError( + f"Couldn't parse latex symbol {latex_symbol_str} " + f"to sympy symbol." + ) + substitutions[latex_symbol] = Symbol(sympy_symbol_str) + + parsed_responses = set() + for expression in response_set: + try: + expression = latex2sympy(expression, substitutions) + if isinstance(expression, list): + expression = expression.pop() + if simplify is True: + expression = expression.simplify() + except Exception as e: + raise ValueError(str(e)) + + parsed_responses.add(str(expression.xreplace(substitutions))) + + if len(parsed_responses) < 2: + return parsed_responses.pop() + else: + return '{'+', '.join(parsed_responses)+'}' + + +def sanitise_latex(response): + response = "".join(response.split()) + response = response.replace('~', ' ') + wrappers = [r"\mathrm", r"\text"] + for wrapper in wrappers: + processed_response = [] + index = 0 + while index < len(response): + wrapper_start = response.find(wrapper+"{", index) + if wrapper_start > -1: + processed_response.append(response[index:wrapper_start]) + wrapper_end = find_matching_parenthesis(response, wrapper_start+1, delimiters=('{', '}')) + inside_wrapper = response[(wrapper_start+len(wrapper+"{")):wrapper_end] + processed_response.append(inside_wrapper) + index = wrapper_end+1 + else: + processed_response.append(response[index:]) + index = len(response) + response = "".join(processed_response) + return response diff --git a/evaluation_function/slr_parsing_utilities.py b/app/utility/slr_parsing_utilities.py similarity index 95% rename from evaluation_function/slr_parsing_utilities.py rename to app/utility/slr_parsing_utilities.py index db68e28..20cac82 100644 --- a/evaluation_function/slr_parsing_utilities.py +++ b/app/utility/slr_parsing_utilities.py @@ -22,6 +22,7 @@ def catch_undefined(label, content, original, start, end): def proceed(production, output, tag_handler): return output + def package(production, output, tag_handler): label = production[0].label handle = production[1] @@ -56,7 +57,7 @@ def join(production, output, tag_handler): for node in output[-len(handle):]: try: content.append(node.content_string()) - except: + except Exception: content.append(node.content) joined_content = "".join(content) joined_end = output[-1].end @@ -127,7 +128,7 @@ def wrap(production, output, tag_handler): end_index = end_delim.end content = output[-number_of_elements:] output = output[0:-number_of_elements] - start_delim = output.pop() + _ = output.pop() for k, elem in enumerate(content): if isinstance(elem, Token) and not isinstance(elem, ExprNode): content[k] = ExprNode(elem, [], tag_handler=tag_handler) @@ -638,7 +639,8 @@ def scan(self, expr, mode="expression"): token_list = self.token_list tokens = [] - new_token = lambda l, c, s, e: Token(l, c, expr, s, e) + def new_token(token_label, token_content, token_start, token_end): + return Token(token_label, token_content, expr, token_start, token_end) token_catch_undefined = [x for x in token_list if len(x) > 2 and x[2] == catch_undefined] if len(token_catch_undefined) > 1: @@ -841,46 +843,3 @@ def parse(self, input_tokens, verbose=False): raise Exception(f"{'-'*m}\nINVALID ENTRY:\n{'-'*m}\naccepted: {input_tokens[:-len(tokens)]}\ncurrent: {a}\nremaining: {tokens}\nstack: {stack}\noutput: {output}\n{'-'*m}") break return output - -# ------- -# ------- -# TESTING -# ------- -# ------- - - -if __name__ == "__main__": - - productions = [ - ("S", "E", create_node), - ("E", "E+E", infix), - ("E", "E*E", infix), - ("E", "(E)", group), - ("E", "I", relabel) - ] - - start_symbol = "S" - end_symbol = "$" - null_symbol = "e" - - infix_operators = ["+", "*"] - delimiters = ["(", ")"] - token_list = [(start_symbol, "START"), ("E", "EXPRESSION"), ("I", "IDENTIFIER")]\ - + [(x, "INFIX"+x) for x in infix_operators]\ - + [("(", "START_DELIMITER"), (")", "END_DELIMITER")]\ - + [(end_symbol, "END"), (null_symbol, "NULL")] - - test_parser = SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) - print(test_parser.parsing_table_to_string()) - - ref_string = '\t+\t*\t(\t)\tI\t$\te\tS\tE\n0\tx\tx\ts2\tx\ts3\tx\tx\tx\ts1\t\n1\ts4\ts5\tx\tx\tx\tr0\tx\tx\tx\t\n2\tx\tx\ts2\tx\ts3\tx\tx\tx\ts6\t\n3\tr4\tr4\tx\tr4\tx\tr4\tx\tx\tx\t\n4\tx\tx\ts2\tx\ts3\tx\tx\tx\ts7\t\n5\tx\tx\ts2\tx\ts3\tx\tx\tx\ts8\t\n6\ts4\ts5\tx\ts9\tx\tx\tx\tx\tx\t\n7\tr1\ts5\tx\tr1\tx\tr1\tx\tx\tx\t\n8\tr2\tr2\tx\tr2\tx\tr2\tx\tx\tx\t\n9\tr3\tr3\tx\tr3\tx\tr3\tx\tx\tx\t\n' - - if ref_string != test_parser.parsing_table_to_string(): - print(" ***************************************\n * WARNING: test parsing table changed *\n ***************************************") - - test_tokens = test_parser.scan("(I+I)*I+I") - print([str(token.label) for token in test_tokens]) - - output = test_parser.parse(test_tokens) - print(output) - print(output[0].tree_string()) diff --git a/app/utility/syntactical_comparison_utilities.py b/app/utility/syntactical_comparison_utilities.py new file mode 100644 index 0000000..e75da32 --- /dev/null +++ b/app/utility/syntactical_comparison_utilities.py @@ -0,0 +1,96 @@ +import re + +is_nonnegative_number_regex = '((0|[1-9]\d*)?(\.\d+)?(?<=\d)(e-?(0|[1-9]\d*))?)' + + +is_number_regex = '(-?(0|[1-9]\d*)?(\.\d+)?(?<=\d)( *(e|E|\*10^|\*10\*\*)-?(0|[1-9]\d*))?)' + + +def is_number(string): + match_content = re.fullmatch(is_number_regex, string) + return match_content is not None and len(match_content.group(0)) > 0 + + +def is_complex_number_on_cartesian_form(string): + string = "".join(string.split()) + result = re.fullmatch(is_number_regex+"?\+?"+is_number_regex+"?\*?I?", string) + return result is not None + + +def is_complex_number_on_exponential_form(string): + string = "".join(string.split()) + result = re.fullmatch(is_number_regex+"?\*?(E\^|E\*\*|exp)\(?"+is_number_regex+"*\*?I\)?", string) + return result is not None + + +def escape_regex_reserved_characters(string): + list = '+*?^$.[]{}()|/' + string = string.replace('\\', '\\\\') + for s in list: + string = string.replace(s, '\\'+s) + return string + + +def generate_arbitrary_number_pattern_matcher(string): + non_numbers = [] + number_pattern = '(\\('+is_number_regex+'\\))' + nonneg_number_pattern = is_nonnegative_number_regex + full_pattern = '('+number_pattern+'|'+nonneg_number_pattern+')' + number = re.search(number_pattern, string) + nonneg_number = re.search(nonneg_number_pattern, string) + start = 0 + end = 0 + offset = 0 + while (number is not None) or (nonneg_number is not None): + start_number = len(string) + end_number = len(string) + start_nonneg_number = len(string) + end_nonneg_number = len(string) + if number is not None: + start_number, end_number = number.span() + if nonneg_number is not None: + start_nonneg_number, end_nonneg_number = nonneg_number.span() + if start_number < start_nonneg_number: + start, end = number.span() + else: + start, end = nonneg_number.span() + start += offset + end += offset + non_number = escape_regex_reserved_characters(string[offset:start]) + if len(non_number) > 0: + non_number = '('+non_number+')' + non_number = ''.join(non_number.split()) + non_numbers.append(non_number) + offset = end + number = re.search(number_pattern, string[offset:]) + nonneg_number = re.search(nonneg_number_pattern, string[offset:]) + non_numbers.append(string[offset:]) + pattern = full_pattern.join(non_numbers) + + def matcher(comp_string): + comp_string = ''.join(comp_string.split()) + result = re.fullmatch(pattern, comp_string) + return result is not None + return matcher + + +patterns = { + "NUMBER": { + "matcher": is_number, + "name": "simplified number", + "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both numbers written in simplified form.", + "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both numbers written in simplified form.", + }, + "CARTESIAN": { + "matcher": is_complex_number_on_cartesian_form, + "name": "cartesian", + "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in cartesian form", + "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in cartesian form, i.e. $a+bi$.", + }, + "EXPONENTIAL": { + "matcher": is_complex_number_on_exponential_form, + "name": "exponential", + "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in exponential form", + "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written in exponential form, i.e. $a exp(bi)$.", + }, +} diff --git a/evaluation_function/unit_system_conversions.py b/app/utility/unit_system_conversions.py similarity index 97% rename from evaluation_function/unit_system_conversions.py rename to app/utility/unit_system_conversions.py index dd5e725..7fc4926 100644 --- a/evaluation_function/unit_system_conversions.py +++ b/app/utility/unit_system_conversions.py @@ -79,15 +79,15 @@ This is the subset of common symbols whose short form symbols are allowed """ set_of_very_common_units_in_SI = { - ('radian', 'r', '(1)', tuple(), ('radians',)), # Note: here 'r' is used instead of the more common 'rad' to avoid collision - ('steradian', 'sr', '(1)', tuple(), ('steradians',)), + ('radian', 'r', '(1/(2*pi))', tuple(), ('radians',)), # Note: here 'r' is used instead of the more common 'rad' to avoid collision + ('steradian', 'sr', '(1/(4*pi))', tuple(), ('steradians',)), ('minute', 'min', '(60*second)', tuple(), ('minutes',)), ('hour', 'h', '(3600*second)', tuple(), ('hours',)), - ('degree', 'deg', '(pi/180)', tuple(), ('degrees',)), + ('degree', 'deg', '(1/360)', tuple(), ('degrees',)), ('litre', 'L', '(10**(-3)*metre**3)', ('liter',), ('litres,liters',)), ('metricton', 't', '(10**3*kilogram)', ('tonne',), ('tonnes',)), ('neper', 'Np', '(1)', ('Neper',), ('nepers', 'Nepers')), - ('bel', 'B', '((1/2)*2.30258509299405)', ('Bel',), ('bels', 'Bels')), # Note: log(10) = 2.30258509299405 in base 2 + ('bel', 'B', '((1/2)*2.30258509299405)', ('Bel',), ('bels', 'Bels')), # Note: log(10) = 2.30258509299405 in base 2 ('electronvolt', 'eV', '(1.60218*10**(-19)*joule)', tuple(), ('electronvolts',)), ('atomic_mass_unit', 'u', '(1.66054*10**(-27)*kilogram)', tuple(), ('atomic_mass_units',)), ('angstrom', 'Å', '(10**(-10)*metre)', ('Angstrom', 'Ångström'), ('angstroms', 'Angstroms')), diff --git a/config.json b/config.json index 99160fe..46bfeb7 100644 --- a/config.json +++ b/config.json @@ -1,3 +1,3 @@ { - "EvaluationFunctionName": "symbolicEqualDev" -} + "EvaluationFunctionName": "compareExpressions" +} \ No newline at end of file diff --git a/docs/dev.md b/docs/dev.md deleted file mode 100644 index 7659400..0000000 --- a/docs/dev.md +++ /dev/null @@ -1,29 +0,0 @@ -# YourFunctionName -*Brief description of what this evaluation function does, from the developer perspective* - -## Inputs -*Specific input parameters which can be supplied when the `eval` command is supplied to this function.* - -## Outputs -*Output schema/values for this function* - -## Examples -*List of example inputs and outputs for this function, each under a different sub-heading* - -### Simple Evaluation - -```python -{ - "example": { - "Something": "something" - } -} -``` - -```python -{ - "example": { - "Something": "something" - } -} -``` \ No newline at end of file diff --git a/docs/quantity_comparison_graph.svg b/docs/quantity_comparison_graph.svg deleted file mode 100644 index e57638d..0000000 --- a/docs/quantity_comparison_graph.svg +++ /dev/null @@ -1,478 +0,0 @@ - - - - - - -G - - - -START - - -START - - - - - -MISSING_VALUE - - -not(has(value(response))) and has(value(answer)) - - - - - -START->MISSING_VALUE - - - - - -RESULT_NODE_0 - - -True - - - - - -MISSING_VALUE:s->RESULT_NODE_0:n - - - - -RESULT_NODE_1 - - -False - - - - - -MISSING_VALUE:s->RESULT_NODE_1:n - - - - -END - - -END - - - - - -RESULT_NODE_0:s->END:n - - - - - -MISSING_UNIT - - -not(has(unit(response))) and has(unit(answer)) - - - - - -RESULT_NODE_1:s->MISSING_UNIT:n - - - - - -RESULT_NODE_2 - - -True - - - - - -MISSING_UNIT:s->RESULT_NODE_2:n - - - - -RESULT_NODE_3 - - -False - - - - - -MISSING_UNIT:s->RESULT_NODE_3:n - - - - -RESULT_NODE_2:s->END:n - - - - - -UNEXPECTED_VALUE - - -has(value(response)) and not(has(value(answer))) - - - - - -RESULT_NODE_3:s->UNEXPECTED_VALUE:n - - - - - -RESULT_NODE_4 - - -True - - - - - -UNEXPECTED_VALUE:s->RESULT_NODE_4:n - - - - -RESULT_NODE_5 - - -False - - - - - -UNEXPECTED_VALUE:s->RESULT_NODE_5:n - - - - -RESULT_NODE_4:s->END:n - - - - - -UNEXPECTED_UNIT - - -has(unit(response)) and not(has(unit(answer))) - - - - - -RESULT_NODE_5:s->UNEXPECTED_UNIT:n - - - - - -RESULT_NODE_6 - - -True - - - - - -UNEXPECTED_UNIT:s->RESULT_NODE_6:n - - - - -RESULT_NODE_7 - - -False - - - - - -UNEXPECTED_UNIT:s->RESULT_NODE_7:n - - - - -RESULT_NODE_6:s->END:n - - - - - -RESPONSE_DIMENSION_MATCHES_ANSWER - - -dimension(QUANTITY) matches dimension(QUANTITY) - - - - - -RESULT_NODE_7:s->RESPONSE_DIMENSION_MATCHES_ANSWER:n - - - - - -RESULT_NODE_8 - - -True - - - - - -RESPONSE_DIMENSION_MATCHES_ANSWER:s->RESULT_NODE_8:n - - - - -RESULT_NODE_9 - - -False - - - - - -RESPONSE_DIMENSION_MATCHES_ANSWER:s->RESULT_NODE_9:n - - - - -RESPONSE_MATCHES_ANSWER - - -response matches answer - - - - - -RESULT_NODE_8:s->RESPONSE_MATCHES_ANSWER:n - - - - - -RESULT_NODE_9:s->END:n - - - - - -RESULT_NODE_10 - - -True - - - - - -RESPONSE_MATCHES_ANSWER:s->RESULT_NODE_10:n - - - - -RESULT_NODE_11 - - -False - - - - - -RESPONSE_MATCHES_ANSWER:s->RESULT_NODE_11:n - - - - -RESPONSE_AND_ANSWER_HAS_UNITS - - -has(unit(response)) and has(unit(answer)) - - - - - -RESULT_NODE_10:s->RESPONSE_AND_ANSWER_HAS_UNITS:n - - - - - -RESULT_NODE_11:s->END:n - - - - - -RESULT_NODE_12 - - -True - - - - - -RESPONSE_AND_ANSWER_HAS_UNITS:s->RESULT_NODE_12:n - - - - -RESULT_NODE_13 - - -False - - - - - -RESPONSE_AND_ANSWER_HAS_UNITS:s->RESULT_NODE_13:n - - - - -PREFIX_IS_LARGE - - -expanded_unit(response) >= 1000*expanded_unit(answer) - - - - - -RESULT_NODE_12:s->PREFIX_IS_LARGE:n - - - - - -RESULT_NODE_13:s->END:n - - - - - -RESULT_NODE_14 - - -True - - - - - -PREFIX_IS_LARGE:s->RESULT_NODE_14:n - - - - -RESULT_NODE_15 - - -False - - - - - -PREFIX_IS_LARGE:s->RESULT_NODE_15:n - - - - -RESULT_NODE_14:s->END:n - - - - - -PREFIX_IS_SMALL - - -expanded_unit(response)*1000 <= expanded_unit(answer) - - - - - -RESULT_NODE_15:s->PREFIX_IS_SMALL:n - - - - - -RESULT_NODE_16 - - -True - - - - - -PREFIX_IS_SMALL:s->RESULT_NODE_16:n - - - - -RESULT_NODE_17 - - -False - - - - - -PREFIX_IS_SMALL:s->RESULT_NODE_17:n - - - - -RESULT_NODE_16:s->END:n - - - - - -RESULT_NODE_17:s->END:n - - - - - diff --git a/docs/user.md b/docs/user.md deleted file mode 100644 index 20f0e07..0000000 --- a/docs/user.md +++ /dev/null @@ -1,138 +0,0 @@ -# CompareExpressions - -This function utilises the [`SymPy`](https://docs.sympy.org/latest/index.html) to provide a maths-aware comparsion of a student's response to the correct answer. This means that mathematically equivalent inputs will be marked as correct. Note that `pi` is a reserved constant and cannot be used as a symbol name. - -Note that this function is designed to handle comparisons of mathematical expressions but has some limited ability to handle comparison of equalities as well. More precisely, if the answer is of the form $f(x_1,\ldots,x_n) = g(x_1,\ldots,x_n)$ and the response is of the form $\tilde{f}(x_1,\ldots,x_n) = \tilde{g}(x_1,\ldots,x_n)$ then the function checks if $f(x_1,\ldots,x_n) - g(x_1,\ldots,x_n)$ is a multiple of $\tilde{f}(x_1,\ldots,x_n) / \tilde{g}(x_1,\ldots,x_n)$. - -## Inputs - -### Optional parameters - -There are nine optional parameters that can be set: `complexNumbers`, `convention`, `criteria`, `multiple_answers_criteria`, `elementary_functions`, `feedback_for_incorrect_response`, `physical_quantity`, `plus_minus`/`minus_plus` `specialFunctions`, `strict_syntax`, `symbol_assumptions`. - -## `complexNumbers` - -If you want to use `I` for the imaginary constant, set the grading parameter `complexNumbers` to True. - -## `convention` - -Changes the implicit multiplication convention. If unset it will default to `equal_precedence`. - -If set to `implicit_higher_precedence` then implicit multiplication will have higher precedence than explicit multiplication, i.e. `1/ab` will be equal to `1/(ab)` and `1/a*b` will be equal to `(1/a)*b`. - -If set to `equal_precedence` then implicit multiplication will have the same precedence than explicit multiplication, i.e. both `1/ab` and `1/a*b` will be equal to `(1/a)*b`. - -## `criteria` - -The `criteria` parameter can be used to customize the comparison performed by the evaluation function. If unset the evaluation function will will default to checking if the answer and response are symbolically equal. - -The `criteria` parameter takes a string that defines a set of (comma separated) mathematical statements. If all statements in the list are true the response is considered correct. - -The `criteria` parameter reserves `response` and `answer` as keywords that will be replaced y the response and answer respectively when the criteria is checked. Setting `criteria` to `answer=response` is gives the same behaviour as leaving `criteria` unset. - -**Note:** Currently the `criteria` parameter is ignored if `physical_quantity` is set to true. - -**Note:** The `criteria` parameters functionality is currently under development and will rarely produce appropriate feedback and can be quite difficult to debug. - -## `elementary_functions` - -When using implicit multiplication function names with multiple characters are sometimes split and not interpreted properly. Setting `elementary_functions` to true will reserve the function names listed below and prevent them from being split. If a name is said to have one or more alternatives this means that it will accept the alternative names but the reserved name is what will be shown in the preview. - -`sin`, `sinc`, `csc` (alternative `cosec`), `cos`, `sec`, `tan`, `cot` (alternative `cotan`), `asin` (alternative `arcsin`), `acsc` (alternatives `arccsc`, `arccosec`), `acos` (alternative `arccos`), `asec` (alternative `arcsec`), `atan` (alternative `arctan`), `acot` (alternatives `arccot`, `arccotan`), `atan2` (alternative `arctan2`), `sinh`, `cosh`, `tanh`, `csch` (alternative `cosech`), `sech`, `asinh` (alternative `arcsinh`), `acosh` (alternative `arccosh`), `atanh` (alternative `arctanh`), `acsch` (alternatives `arccsch`, `arcosech`), `asech` (alternative `arcsech`), `exp` (alternative `Exp`), `E` (equivalent to `exp(1)`, alternative `e`), `log`, `sqrt`, `sign`, `Abs` (alternative `abs`), `Max` (alternative `max`), `Min` (alternative `min`), `arg`, `ceiling` (alternative `ceil`), `floor` - -## `feedback_for_incorrect_response` -All feedback for all incorrect responses will be replaced with the string that this parameter is set to. - -## `multiple_answers_criteria` - -The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively. - -Answers or responses that contain $\pm$ or $\mp$ has two possible interpretations which requires further criteria for equality. The grading parameter `multiple_answers_criteria` controls this. The default setting, `all`, is that each answer must have a corresponding answer and vice versa. The setting `all_responses` check that all responses are valid answers and the setting `all_answers` checks that all answers are found among the responses. - -## `physical_quantity` - -If unset, `physical_quantity` will default to `false`. - -If `physical_quantity` is set to `true` the answer and response will interpreted as a physical quantity using units and conventions decided by the `strictness` and `units_string` parameters. - -**Remark:** Setting `physical_quantity` to `true` will also mean that comparisons will be done numerically. If neither the `atol` nor `rtol` parameters are set, the evaluation function will choose a relative error based on the number of sigificant digits given in the answer. - -When `physical_quantity` the evaluation function will generate feedback based on the flowchart below. Hovering over a criterion node will show a short natural language description of the criterion. Hovering over a result node will show the feedback produced so far. - -**Remark:** In some browser it is necessary to right-click and open the image in a separate tab in order for the tooltips to show up on hover. - -![A flowchart describing the criteria that are checked for physical quantities and what feedback they produce.](quantity_comparison_graph.svg) - -## `plus_minus` and `minus_plus` - -The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively. - -To use other symbols for $\pm$ and $\mp$ set the grading parameters `plus_minus` and `minus_plus` to the desired symbol. **Remark:** symbol replacement is brittle and can have unintended consequences. - -## `specialFunctions` - -If you want to use the special functions `beta` (Euler Beta function), `gamma` (Gamma function) and `zeta` (Riemann Zeta function), set the grading parameter `specialFunctions` to True. - -## `strict_syntax` - -If `strict_syntax` is set to true then the answer and response must have `*` or `/` between each part of the expressions and exponentiation must be done using `**`, e.g. `10*x*y/z**2` is accepted but `10xy/z^2` is not. - -If `strict_syntax` is set to false, then `*` can be omitted and `^` used instead of `**`. In this case it is also recommended to list any multicharacter symbols expected to appear in the response as input symbols. - -By default `strict_syntax` is set to true. - -## `symbol_assumptions` - -This input parameter allows the author to set an extra assumption each symbol. Each assumption should be written on the form `('symbol','assumption name')` and all pairs concatenated into a single string. - -The possible assumption `constant`, `function` as well as those listed here: -[`SymPy Assumption Predicates`](https://docs.sympy.org/latest/guides/assumptions.html#predicates) - -**Note:** Writing a symbol which denotes a function without its arguments, e.g. `T` instead of `T(x,t)`, is prone to cause errors. - -## Examples - -Implemented versions of these examples can be found in the module 'Examples: Evaluation Functions'. - -### 1 Setting input symbols to be assumed positive to avoid issues with fractional powers - -In general $\frac{\sqrt{a}}{\sqrt{b}} \neq \sqrt{\frac{a}{b}}$ but if $a > 0$ and $b > 0$ then $\frac{\sqrt{a}}{\sqrt{b}} = \sqrt{\frac{a}{b}}$. The same is true for other fractional powers. - -So if expressions like these are expected in the answer and/or response then it is a good idea to use the `symbol_assumptions` parameter to note that $a > 0$ and $b > 0$. This can be done by setting `symbol_assumptions` to `('a','positive') ('b','positive')`. - -The example given in the example problem set uses two EXPRESSION response areas. Both response areas uses `compareExpression` with answer `sqrt(a/b)`, `strict_syntax` set to false, `elementary_functions` set to true. One response area leaves `symbol_assumptions` unset and the other sets the parameter as described in the previous paragraph. Some examples of expressions that are accepted as correct when positivity is assumed: -`sqrt(a)/sqrt(b)`, `(a/b)**(1/2)`, `a**(1/2)/b**(1/2)`, `(a/b)^(0.5)`, `a^(0.5)/b^(0.5)` - -### 2 Using plus/minus symbols - -The $\pm$ and $\mp$ symbols can be represented in the answer or response by `plus_minus` and `minus_plus` respectively. To use other symbols for $\pm$ and $\mp$ set the grading parameters `plus_minus` and `minus_plus` to the desired symbol. **Remark:** symbol replacement is brittle and can have unintended consequences. - -It is considered good practice to make sure that the appropriate notation for $\pm$ and $\mp$ are added and displayed as input symbols in order to minimize confusion. - -The example given in the example problem set uses an EXPRESSION response area that uses `compareExpression` with answer `plus_minus x**2 + minus_plus y**2`, `strict_syntax` set to false and `elementary_function` set to true. Some examples of expressions that are accepted as correct: -`plus_minus x**2 + minus_plus y**2`, `- minus_plus x**2 + minus_plus y**2`, `- minus_plus x^2 minus_plus y^2`, `- minus_plus x^2 - plus_minus y^2` - -### 3 Equalities in the answer and response - -There is (limited) support for using equalities in the response and answer. - -The example given in the example problem set uses an EXPRESSION response area that uses `compareExpression` with answer `x**2-5*y**2-7=0`. Some examples of expressions that are accepted as correct: -`x**2-5*y**2-7=0`, `x^2 = 5y^2+7`, `2x^2 = 10y^2+14` - -### 4 Checking the value of an expression or a physical quantity - -If the parameter `physical_quantity` is set to true, the evaluation function can handle expressions that describe physical quantities. Which units are permitted and how they should be written depends on the `units_string` and `strictness` parameters respectively. - -There are three examples in the example problem set. Each examples uses an EXPRESSION response area that uses `compareExpression` with answer `strict_syntax` set to false and `physical_quantity` set to true. - -#### Example (a) - -Here the answer is `2.00 km/h`. The parameters `strictness` and `units_string` are left unset which is equivalent to setting `strictness` to `natural`, and `units_string` to `SI common imperial`. Thus this response area accepts a wide range of responses, e.g. `2.00 kilometre/hour`, `2 km/h`, `2000 meter/hour`, `2 metre/millihour` - -#### Example (b) - -Here the answer is `2.00 km/h`. To restrict the answers to SI units `strictness` is set to `strict` and `units_string` is set to `SI`. Some examples of accepted responses are: `0.556 metre/second`, `5.56 dm/s`, `55.6 centimetre second^(-1)` - -#### Example (c) - -Here the answer is `2.00 km/h`. To restrict the answers to imperial units `strictness` is set to `strict` and `units_string` is set to `imperial common`. Accepted response: `1.24 mile/hour` diff --git a/evaluation_function/benchmarking.py b/evaluation_function/benchmarking.py deleted file mode 100644 index d731cb7..0000000 --- a/evaluation_function/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} - }, -] \ No newline at end of file diff --git a/evaluation_function/comparison_utilities.py b/evaluation_function/comparison_utilities.py deleted file mode 100644 index 80932c8..0000000 --- a/evaluation_function/comparison_utilities.py +++ /dev/null @@ -1,21 +0,0 @@ -def compute_relative_tolerance_from_significant_decimals(string): - rtol = None - string = string.strip() - separators = "e*^ " - separator_indices = [] - for separator in separators: - if separator in string: - separator_indices.append(string.index(separator)) - else: - separator_indices.append(len(string)) - index = min(separator_indices) - significant_characters = string[0:index].replace(".", "") - index = 0 - for c in significant_characters: - if c in "-0": - index += 1 - else: - break - significant_characters = significant_characters[index:] - rtol = 5*10**(-len(significant_characters)) - return rtol \ No newline at end of file diff --git a/evaluation_function/criteria_utilities.py b/evaluation_function/criteria_utilities.py deleted file mode 100644 index a92967d..0000000 --- a/evaluation_function/criteria_utilities.py +++ /dev/null @@ -1,242 +0,0 @@ -import pydot # Used for creating visualization of criteria graph - -def undefined_key(key): - raise KeyError("No feedback defined for key: "+str(key)) - - -def no_feedback(inputs): - return "" - - -def flip_bool_result(result): - return not result - -class Criterion: - - def __init__(self, check, feedback_for_undefined_key=undefined_key, doc_string=None): - self.check = check - self.feedback = dict() - self.feedback_for_undefined_key = feedback_for_undefined_key - self.doc_string = doc_string - return - - def __getitem__(self, key): - if key in self.feedback.keys(): - return self.feedback[key] - else: - return self.feedback_for_undefined_key - - def __setitem__(self, key, value): - self.feedback.update({key: value}) - return - -undefined_optional_parameter = object() - -class CriteriaGraphNode: - - def __init__(self, label, criterion=None, children=undefined_optional_parameter, override=True, result_map=None): - self.label = label - self.criterion = criterion - self.result_map = result_map - self.override = override - if children is undefined_optional_parameter: - self.children = dict() - else: - self.children = children - return - - def __getitem__(self, key): - if key in self.children.keys(): - return self.children[key] - else: - return None - - def __setitem__(self, key, value): - self.children.update({key: value}) - return - - def traverse(self, check, previous_result=None): - if self.criterion is None or self.override is False: - result = previous_result - else: - result = check(self.label, self.criterion) - if self.children is not None: - try: - if self.children[result] is not None: - prev_res = result - if self.result_map is not None: - prev_res = self.result_map(result) - result = self.children[result].traverse(check, previous_result=prev_res) - except KeyError as exc: - raise Exception(f"Unexpected result ({str(result)}) in criteria {self.label}.") from exc - return result - - def get_by_label(self, label): - if self.label == label: - return self - else: - if self.children is not None: - for child in self.children.values(): - result = None - if child is not None: - result = child.get_by_label(label) - if result is not None: - return result - return None - -END = CriteriaGraphNode("END", children=None) - -class CriteriaGraphContainer: - ''' - This container class provides the following utility functionality: - - Ensures that an appropriate START node is created - - Streamlines graph specification via the attach and finish functions - ''' - # Consider adding the following functionality: - # - Allow attaching graphnodes or other containers directly - - def __init__(self, criteria_dict): - self.START = CriteriaGraphNode("START") - self.criteria = criteria_dict - return - - def get_by_label(self, label): - return self.START.get_by_label(label) - - def attach(self, source, label, result=None, criterion=undefined_optional_parameter, **kwargs): - try: - source = self.get_by_label(source) - except KeyError as exc: - raise KeyError(f"Unknown connection node: {source}") from exc - if criterion is undefined_optional_parameter: - try: - criterion = self.criteria[label] - except KeyError as exc: - raise KeyError(f"Unknown criteria: {label}") from exc - source[result] = CriteriaGraphNode(label, criterion, **kwargs) - return - - def finish(self, source, result): - try: - source = self.get_by_label(source) - except KeyError as exc: - raise KeyError(f"Unknown connection node: {source}") from exc - source[result] = END - return - -def traverse(node, check): - if isinstance(node, CriteriaGraphContainer): - node = node.START - result = None - while node.children is not None: - result_map = None - override = node.override - if node.result_map is not None: - result_map = node.result_map - new_result = result - if node.criterion is not None: - new_result = check(node.label, node.criterion) - try: - if node.children[new_result] is not None: - node = node.children[new_result] - except KeyError as exc: - raise Exception(f"Unexpected result ({str(new_result)}) in criteria {node.label}.") from exc - if result_map is not None: - new_result = result_map(new_result) - if override is True: - result = new_result - return result - -def generate_svg(root_node, filename, dummy_input=None): - # Generates a dot description of the subgraph with the given node as root and uses graphviz generate a visualization the graph in svg format - splines = "spline" - style = "filled" - rankdir = "TB" - result_compass = None - if rankdir == "TB": - result_compass = ("n","s") - if rankdir == "LR": - result_compass = ("w","e") - graph_attributes = [f'splines="{splines}"', f'node [style="{style}"]', f'rankdir="{rankdir}"'] - criterion_shape = "polygon" - criterion_color = "#00B8D4" - criterion_fillcolor = "#E5F8FB" - criterion_fontcolor = "#212121" - result_shape = "ellipse" - result_color = "#212121" - result_fillcolor = "#C5CAE9" - result_fontcolor = "#212121" - special_shape = "ellipse" - special_color = "#2F3C86" - special_fillcolor = "#4051B5" - special_fontcolor = "#FFFFFF" - criterion_params = (criterion_shape, criterion_color, criterion_fillcolor, criterion_fontcolor) - result_params = (result_shape, result_color, result_fillcolor, result_fontcolor) - special_params = (special_shape, special_color, special_fillcolor, special_fontcolor) - nodes = [] - edges = [] - number_of_result_nodes = 0 - result_node_paths = [] - results = [] - previous_result_node_index = -1 - nodes_to_be_processed = [(previous_result_node_index, root_node)] - nodes_already_processed = [] - while len(nodes_to_be_processed) > 0: - previous_result_node_index, node = nodes_to_be_processed.pop() - label = node.label - tooltip = node.label - shape, color, fillcolor, fontcolor = special_params - feedback_descriptions = dict() - if node.criterion is not None: - shape, color, fillcolor, fontcolor = criterion_params - label = node.criterion.check - feedback_descriptions.update({key: value(dummy_input) for (key, value) in node.criterion.feedback.items()}) - if node.criterion.doc_string is not None: - tooltip = node.criterion.doc_string - nodes.append(f'{node.label} [label="{label}" tooltip="{tooltip}" shape="{shape}" color="{color}" fillcolor="{fillcolor}" fontcolor="{fontcolor}"]') - if node not in nodes_already_processed: - nodes_already_processed.append(node) - if node.children is not None: - for (result, target) in node.children.items(): - current_result_node_index = previous_result_node_index - if result is None: - edges.append(f'{node.label} -> {target.label}') - else: - shape, color, fillcolor, fontcolor = result_params - result_label = f'RESULT_NODE_{str(number_of_result_nodes)}' - result_feedback = feedback_descriptions.get(result,"") - result_feedback_info = [' + '+feedback_descriptions.get(result,"")] - if node.override is True: - is_correct = result - elif node.override is False: - is_correct = results[previous_result_node_index] - if node.result_map is not None: - is_correct = node.result_map(is_correct) - results.append(is_correct) - if result_feedback.strip() == "": - result_feedback = [] - result_feedback_info = [' + No new feedback produced'] - else: - result_feedback = [" ● "+result_feedback] - previous_feedback = [] - if previous_result_node_index >= 0: - previous_feedback = result_node_paths[previous_result_node_index] - result_node_paths.append(previous_feedback+result_feedback) - if is_correct is True: - tooltip = ['Response is CORRECT'] - if is_correct is False: - tooltip = ['Response is INCORRECT'] - tooltip = "\n".join(tooltip+previous_feedback+result_feedback_info) - nodes.append(f'{result_label} [label="{str(result)}" tooltip="{tooltip}" shape="{shape}" color="{color}" fillcolor="{fillcolor}" fontcolor="{fontcolor}"]') - current_result_node_index = number_of_result_nodes - number_of_result_nodes += 1 - edges.append(f'{node.label}:{result_compass[1]} -> {result_label}:{result_compass[0]} [arrowhead="none"]') - edges.append(f'{result_label}:{result_compass[1]} -> {target.label}:{result_compass[0]}') - nodes_to_be_processed.append((current_result_node_index, target)) - dot_preamble = 'digraph {'+'\n'.join(graph_attributes)+'\n' - dot_postamble = '\n}' - dot_string = dot_preamble+"\n".join(nodes+edges)+dot_postamble - graphs = pydot.graph_from_dot_data(dot_string) - graph = graphs[0] - graph.write_svg(filename) - return dot_string \ No newline at end of file diff --git a/evaluation_function/dev.py b/evaluation_function/dev.py deleted file mode 100644 index 886d641..0000000 --- a/evaluation_function/dev.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys - -from lf_toolkit.shared.params import Params - -from .evaluation import evaluation_function - -def dev(): - """Run the evaluation function from the command line for development purposes. - - Usage: python -m evaluation_function.dev - """ - if len(sys.argv) < 3: - print("Usage: python -m evaluation_function.dev ") - return - - answer = sys.argv[1] - response = sys.argv[2] - - result = evaluation_function(answer, response, Params()) - - print(result.to_dict()) - -if __name__ == "__main__": - dev() \ No newline at end of file diff --git a/evaluation_function/dev_test.py b/evaluation_function/dev_test.py deleted file mode 100644 index 4a22bcb..0000000 --- a/evaluation_function/dev_test.py +++ /dev/null @@ -1,12 +0,0 @@ -from .evaluation import evaluation_function - -response = r"\\begin{array}{l}\na+b \\text { and more text }\\\\\n\\begin{array}{l}\nq+x \\\\\nc+d\n\\end{array}\n\\end{array}" -answer = "\\begin{array}{l}\na+b \\text { and more text }\\\\\n\\begin{array}{l}\nq+x \\\\\nc+d\n\\end{array}" -params = { - "strict_syntax": False, - "elementary_functions": True, - "is_latex": True, - "text_prototype": True, -} -result = evaluation_function(response, answer, params) -print(result) \ No newline at end of file diff --git a/evaluation_function/evaluation.py b/evaluation_function/evaluation.py deleted file mode 100644 index 3a5fded..0000000 --- a/evaluation_function/evaluation.py +++ /dev/null @@ -1,81 +0,0 @@ -from .evaluation_response_utilities import EvaluationResponse -from .symbolic_comparison_evaluation import evaluation_function as symbolic_comparison -from .slr_quantity import quantity_comparison -from .preview import preview_function - -from .benchmarking import benchmarks -from timeit import default_timer as timer - -def evaluation_function(response, answer, params, include_test_data=False) -> dict: - - if params.get("text_prototype", False) is True: - preview = preview_function(response, params)['preview'] - return {"is_correct": True, "response_latex": preview['latex'], "response_simplified": preview['sympy']} - - if response.lower().startswith("benchmark"): - arg = response.split() - n = 1 - val = True - if len(arg) > 1: - n = int(arg[1]) - if len(arg) > 2: - if arg[2].lower().startswith("f"): - val = False - results = [] - total = 0 - for k, test in enumerate(benchmarks,1): - avg = 0 - for i in range(0,n): - start = timer() - result = evaluation_function( - test["response"], - test["answer"], - test["params"] - ) - end = timer() - avg += end-start - total += avg - avg = avg/n - results.append(f"Time for test {k}: {avg}") - return {"is_correct": val, "feedback": r"
".join(results)+r"
"+"Total: "+str(total)} - - eval_response = EvaluationResponse() - eval_response.is_correct = False - - input_symbols_reserved_words = list(params.get("symbols", dict()).keys()) - - for input_symbol in params.get("symbols", dict()).values(): - input_symbols_reserved_words += input_symbol.get("aliases",[]) - - for input_symbol in params.get("input_symbols", []): - input_symbols_reserved_words += [input_symbol[0]]+input_symbol[1] - - reserved_keywords = ["response", "answer", "plus_minus", "minus_plus", "where"] - reserved_keywords_collisions = [] - for keyword in reserved_keywords: - if keyword in input_symbols_reserved_words: - reserved_keywords_collisions.append(keyword) - if len(reserved_keywords_collisions) > 0: - raise Exception("`"+"`, `".join(reserved_keywords_collisions)+"` are reserved keyword and cannot be used as input symbol codes or alternatives.") - - parameters = { - "comparison": "expression", - "strict_syntax": True, - "reserved_keywords": reserved_keywords, - } - parameters.update(params) - - if params.get("is_latex", False): - response = preview_function(response, params)["preview"]["sympy"] - - if parameters.get("physical_quantity", False) is True: - eval_response = quantity_comparison(response, answer, parameters, eval_response) - else: - eval_response = symbolic_comparison(response, answer, parameters, eval_response) - - if eval_response.is_correct is False and parameters.get("feedback_for_incorrect_response", None) is not None: - result_dict = eval_response.serialise(include_test_data) - result_dict["feedback"] = parameters["feedback_for_incorrect_response"] - return result_dict - - return eval_response.serialise(include_test_data) diff --git a/evaluation_function/evaluation_test.py b/evaluation_function/evaluation_test.py deleted file mode 100644 index 79a92ae..0000000 --- a/evaluation_function/evaluation_test.py +++ /dev/null @@ -1,73 +0,0 @@ -import pytest -import os - -from .evaluation import evaluation_function - -class TestEvaluationFunction(): - """ - TestCase Class used to test the algorithm. - --- - Tests are used here to check that the algorithm written - is working as it should. - - These tests are organised in classes to ensure that the same - calling conventions can be used for tests using unittest and - tests using pytest. - - Read the docs on how to use unittest and pytest here: - https://docs.python.org/3/library/unittest.html - https://docs.pytest.org/en/7.2.x/ - - Use evaluation_function() to call the evaluation function. - """ - - # Import tests that makes sure that mathematical expression comparison works as expected - # from .symbolic_comparison_evaluation_tests import TestEvaluationFunction as TestSymbolicComparison - - # Import tests that makes sure that physical quantities are handled as expected - # from .quantity_comparison_evaluation_tests import TestEvaluationFunction as TestQuantities - - # Import tests that corresponds to examples in documentation and examples module - # from .example_tests import TestEvaluationFunction as TestExamples - - #def test_eval_function_can_handle_latex_input(self): - # response = r"\sin x + x^{7}" - # answer = "sin(x)+x**7" - # params = { - # "strict_syntax": False, - # "elementary_functions": True, - # "is_latex": True - # } - # result = evaluation_function(response, answer, params) - # assert result["is_correct"] is True - - def test_multiline_latex(self): - response = "\\begin{array}{l}\na+b \\text { and more text }\\\\\n\\begin{array}{l}\nq+x \\\\\nc+d\n\\end{array}\n\\end{array}" - answer = "\\begin{array}{l}\na+b \\text { and more text }\\\\\n\\begin{array}{l}\nq+x \\\\\nc+d\n\\end{array}" - params = { - "strict_syntax": False, - "elementary_functions": True, - "is_latex": True, - "text_prototype": True, - } - result = evaluation_function(response, answer, params) - print(result) - assert result.get("is_correct", False) is True - - #def test_incorrect_response_with_custom_feedback(self): - # response = "x+1" - # answer = "x+2" - # response = evaluation_function(response, answer, {"feedback_for_incorrect_response": "Custom feedback"}) - # assert response["is_correct"] is False - # assert response["feedback"] == "Custom feedback" - - #def test_benchmark(self): - # response = "BENCHMARK 10" - # result = evaluation_function(response, "placeholder", {}) - # assert result["is_correct"] is True - # response = "BENCHMARK 10 FALSE" - # result = evaluation_function(response, "placeholder", {}) - # assert result["is_correct"] is False - -if __name__ == "__main__": - pytest.main(['-xk not slow', '--tb=line', '--durations=10', os.path.abspath(__file__)]) diff --git a/evaluation_function/example_tests.py b/evaluation_function/example_tests.py deleted file mode 100644 index 7c8db37..0000000 --- a/evaluation_function/example_tests.py +++ /dev/null @@ -1,233 +0,0 @@ -import os -import pytest - -from .evaluation import evaluation_function -from .preview import preview_function - -class TestEvaluationFunction(): - """ - 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. - """ - - @pytest.mark.parametrize( - "assumptions,value", - [ - (None, False), - ("('a','positive') ('b','positive')", True), - ] - ) - def test_setting_input_symbols_to_be_assumed_positive_to_avoid_issues_with_fractional_powers(self, assumptions, value): - response = "sqrt(a)/sqrt(b)" - answer = "sqrt(a/b)" - params = { - "strict_syntax": False, - "elementary_functions": True, - } - if assumptions is not None: - params.update({"symbol_assumptions": assumptions}) - preview = preview_function(response, params)["preview"] - result = evaluation_function(response, answer, params) - assert preview["latex"] == r"\frac{\sqrt{a}}{\sqrt{b}}" - assert result["is_correct"] == value - - @pytest.mark.parametrize( - "response, response_latex", - [ - ("plus_minus x**2 + minus_plus y**2", r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), - ("- minus_plus x^2 minus_plus y^2", r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}"), - ("- minus_plus x^2 - plus_minus y^2", r"\left\{x^{2} - y^{2},~- x^{2} - - y^{2}\right\}"), - ] - ) - def test_using_plus_minus_symbols(self, response, response_latex): - answer = "plus_minus x**2 + minus_plus y**2" - params = { - "strict_syntax": False, - "elementary_functions": True, - } - preview = preview_function(response, params)["preview"] - result = evaluation_function(response, answer, params) - # Checking latex output disabled as the function return a few different - # variants of the latex in an unpredictable way - # assert preview["latex"] == response_latex - assert result["is_correct"] == True - - @pytest.mark.parametrize( - "response, response_latex", - [ - ("x**2-5*y**2-7=0", r"x^{2} - 5 \cdot y^{2} - 7=0"), - ("x^2 = 5y^2+7", r"x^{2}=5 \cdot y^{2} + 7"), - ("2x^2 = 10y^2+14", r"2 \cdot x^{2}=10 \cdot y^{2} + 14"), - ] - ) - def test_equalities_in_the_answer_and_response(self, response, response_latex): - answer = "x**2-5*y**2-7=0" - params = { - "strict_syntax": False, - "elementary_functions": True, - } - preview = preview_function(response, params)["preview"] - result = evaluation_function(response, answer, params) - assert preview["latex"] == response_latex - assert result["is_correct"] == True - - @pytest.mark.parametrize( - "response, answer, response_latex, value, strictness, units_string, tags", - [ - ("2.00 kilometre/hour", "2.00 km/h", r"2.0~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("2.00", "2.00 km/h", r"2.0", False, None, None, set(["MISSING_UNIT"])), - ("kilometre/hour", "2.00 km/h", r"\frac{\mathrm{kilometre}}{\mathrm{hour}}", False, None, None, set(["MISSING_VALUE"])), - ("2 km/h", "2.00 km/h", r"2~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("2 km", "2.00 km/h", r"2~\mathrm{kilometre}", False, None, None, set(["RESPONSE_DIMENSION_MATCHES_ANSWER"])), - ("0.56 m/s", "2.00 km/h", r"0.56~\frac{\mathrm{metre}}{\mathrm{second}}", False, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("0.556 m/s", "2.00 km/h", r"0.556~\frac{\mathrm{metre}}{\mathrm{second}}", True, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("2000 meter/hour", "2.00 km/h", r"2000~\frac{\mathrm{metre}}{\mathrm{hour}}", True, None, None, {"RESPONSE_MATCHES_ANSWER", "PREFIX_IS_SMALL"}), - ("0.002 megametre/hour", "2.00 km/h", r"0.002~\frac{\mathrm{megametre}}{\mathrm{hour}}", True, None, None, {"RESPONSE_MATCHES_ANSWER", "PREFIX_IS_LARGE"}), - ("2 metre/millihour", "2.00 km/h", r"2~\frac{\mathrm{metre}}{\mathrm{millihour}}", True, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("1.243 mile/hour", "2.00 km/h", r"1.243~\frac{\mathrm{mile}}{\mathrm{hour}}", True, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("109.12 foot/minute", "2.00 km/h", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, None, None, set(["RESPONSE_MATCHES_ANSWER"])), - ("0.556 m/s", "0.556 metre/second", r"0.556~\frac{\mathrm{metre}}{\mathrm{second}}", True, "strict", "SI", set(["RESPONSE_MATCHES_ANSWER"])), - ("5.56 dm/s", "0.556 metre/second", r"5.56~\frac{\mathrm{decimetre}}{\mathrm{second}}", True, "strict", "SI", set(["RESPONSE_MATCHES_ANSWER"])), - ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{(-1)}", True, "strict", "SI", set(["RESPONSE_MATCHES_ANSWER"])), - ("1.24 mile/hour", "1.24 mile/hour", r"1.24~\frac{\mathrm{mile}}{\mathrm{hour}}", True, "strict", "imperial common", set(["RESPONSE_MATCHES_ANSWER"])), - ("2 km/h", "1.24 mile/hour", r"2~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, "strict", "imperial common", set(["RESPONSE_MATCHES_ANSWER"])), # This should be False, but due to SI units being used as base it still works in this case... - ("109.12 foot/minute", "1.24 mile/hour", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, "strict", "imperial common", set(["RESPONSE_MATCHES_ANSWER"])), - ] - ) - def test_checking_the_value_of_a_physical_quantity(self, response, answer, response_latex, value, strictness, units_string, tags): - params = { - "strict_syntax": False, - "elementary_functions": True, - "physical_quantity": True, - } - if strictness is not None: - params.update({"strictness": strictness}) - if units_string is not None: - params.update({"units_string": units_string}) - preview = preview_function(response, params)["preview"] - result = evaluation_function(response, answer, params, include_test_data=True) - assert preview["latex"] == response_latex - assert result["response_latex"] == response_latex - assert tags == set(result["tags"]) - assert result["is_correct"] == value - - @pytest.mark.parametrize( - "answer, atol_response_true, atol_response_false, rtol_response_true, rtol_response_false", - [ - ( - "sqrt(47)+pi", - ["10", "5.1", "14.9"], - ["4.9", "15.1"], - ["10", "5.1", "14.9"], - ["4.9", "15.1"] - ), - ( - "(13/3)^pi", - ["100", "96", "104"], - ["94", "106"], - ["100", "51", "149"], - ["49", "151"], - ), - ( - "9^(e+ln(1.5305))", - ["1000", "996", "1004"], - ["994", "1006"], - ["1000", "501", "1499"], - ["499", "1501"], - ) - ] - ) - 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, - "elementary_functions": True, - "atol": 5, - } - for response in atol_response_true: - result = evaluation_function(response, answer, params) - assert result["is_correct"] == True - for response in atol_response_false: - result = evaluation_function(response, answer, params) - assert result["is_correct"] == False - params = { - "strict_syntax": False, - "elementary_functions": True, - "rtol": 0.5, - } - for response in rtol_response_true: - result = evaluation_function(response, answer, params) - assert result["is_correct"] == True - for response in rtol_response_false: - result = evaluation_function(response, answer, params) - assert result["is_correct"] == False - - @pytest.mark.parametrize( - "response, answer, response_latex, criteria, value, feedback_tags, extra_params", - [ - ("exp(lambda*x)/(1+exp(lambda*x))", "c*exp(lambda*x)/(1+c*exp(lambda*x))", r"\frac{e^{\lambda \cdot x}}{e^{\lambda \cdot x} + 1}", "diff(response,x)=lambda*response*(1-response)", True, [], {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}}), - ("5*exp(lambda*x)/(1+5*exp(lambda*x))", "c*exp(lambda*x)/(1+c*exp(lambda*x))", r"\frac{5 \cdot e^{\lambda \cdot x}}{5 \cdot e^{\lambda \cdot x} + 1}", "diff(response,x)=lambda*response*(1-response)", True, [], {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}}), - ("6*exp(lambda*x)/(1+7*exp(lambda*x))", "c*exp(lambda*x)/(1+c*exp(lambda*x))", r"\frac{6 \cdot e^{\lambda \cdot x}}{7 \cdot e^{\lambda \cdot x} + 1}", "diff(response,x)=lambda*response*(1-response)", False, [], {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}}), - ("c*exp(lambda*x)/(1+c*exp(lambda*x))", "c*exp(lambda*x)/(1+c*exp(lambda*x))", r"\frac{c \cdot e^{\lambda \cdot x}}{c \cdot e^{\lambda \cdot x} + 1}", "diff(response,x)=lambda*response*(1-response)", True, [], {"symbols": {"lambda": {"latex": r"\(\lambda\)", "aliases": []}}}), - ("5x", "5x", r"5 \cdot x", "answer-response = 0, response/answer = 1", True, ["answer-response = 0_TRUE"], dict()), - ("x", "5x", r"x", "answer-response = 0, response/answer = 1", False, ["answer-response = 0_FALSE"], dict()), - ("2x", "x", r"2 \cdot x", "response=2*answer", True, ["RESPONSE_DOUBLE_ANSWER"], dict()), - ("x", "x", "x", "response=2*answer", False, ["RESPONSE_DOUBLE_ANSWER"], dict()), - ("-x", "x", "- x", "answer=-response", True, ["RESPONSE_NEGATIVE_ANSWER"], dict()), - ("x", "x", "x", "response=-answer", False, ["RESPONSE_NEGATIVE_ANSWER"], dict()), - ("1", "1", "1", "response^3-6*response^2+11*response-6=0", True, [], dict()), - ("2", "1", "2", "response^3-6*response^2+11*response-6=0", True, [], dict()), - ("3", "1", "3", "response^3-6*response^2+11*response-6=0", True, [], dict()), - ("4", "1", "4", "response^3-6*response^2+11*response-6=0", False, [], dict()), - ("sin(x)+2", "sin(x)", r"\sin{\left(x \right)} + 2", "Derivative(response,x)=cos(x)", True, [], dict()), - ("sin(x)+2", "sin(x)", r"\sin{\left(x \right)} + 2", "diff(response,x)=cos(x)", True, [], dict()), - ("cos(x)+2", "sin(x)", r"\cos{\left(x \right)} + 2", "diff(response,x)=cos(x)", False, [], dict()), - ] - ) - def test_customizing_comparison(self, response, answer, response_latex, criteria, value, feedback_tags, extra_params): - params = { - "strict_syntax": False, - "elementary_functions": True, - "criteria": criteria, - } - params.update(extra_params) - preview = preview_function(response, params)["preview"] - result = evaluation_function(response, answer, params, include_test_data=True) - assert preview["latex"] == response_latex - assert result["response_latex"] == response_latex - assert result["is_correct"] == value - for feedback_tag in feedback_tags: - assert feedback_tag in result["tags"] - - @pytest.mark.parametrize("response", ["epsilon_r","eps","eps_r","e_r"]) - def test_using_input_symbols_alternatives(self, response): - answer = "epsilon_r" - params = { - "strict_syntax": False, - "elementary_functions": True, - "symbols": { - "epsilon_r": { - "latex": r"\(\epsilon_r\)", - "aliases": ["eps","eps_r","e_r"], - }, - }, - } - preview = preview_function(response, params)["preview"] - result = evaluation_function(response, answer, params) - assert preview["latex"] == r"\epsilon_r" - assert result["is_correct"] == True - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/evaluation_function/feedback/quantity_comparison.py b/evaluation_function/feedback/quantity_comparison.py deleted file mode 100644 index 2266559..0000000 --- a/evaluation_function/feedback/quantity_comparison.py +++ /dev/null @@ -1,116 +0,0 @@ -from ..criteria_utilities import Criterion, CriteriaGraphContainer, flip_bool_result, no_feedback, generate_svg - -class DummyInput: - - def __init__(self, name): - self.name = name - self.unit_latex_string = name - self.value_latex_string = name - self.latex_string = name - return - - def __str__(self): - return self.name - - -criteria = dict() - -criteria["HAS_UNIT"] = Criterion("has(unit(QUANTITY))") -criteria["HAS_UNIT"][True] = lambda inputs: f"{inputs[0].name} has unit: ${inputs[0].unit_latex_string}$" -criteria["HAS_UNIT"][False] = lambda inputs: f"{inputs[0].name} has no unit." - -criteria["HAS_VALUE"] = Criterion("has(value(QUANTITY))") -criteria["HAS_VALUE"][True] = lambda inputs: f"{inputs[0].name} has value: ${inputs[0].value_latex_string}$" -criteria["HAS_VALUE"][False] = lambda inputs: f"{inputs[0].name} has no value." - -criteria["ONLY_VALUE"] = Criterion("has(value(QUANTITY)) and not(has(unit(QUANTITY)))") -criteria["ONLY_VALUE"][True] = lambda inputs: f"{inputs[0].name} has no unit, only value: ${inputs[0].value_latex_string()}$", -criteria["ONLY_VALUE"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["ONLY_UNIT"] = Criterion("not(has(value(QUANTITY))) and has(unit(QUANTITY))") -criteria["ONLY_UNIT"][True] = lambda inputs: f"{inputs[0].name} has no value, only unit: ${inputs[0].unit_latex_string}$", -criteria["ONLY_UNIT"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["FULL_QUANTITY"] = Criterion("has(value(QUANTITY)) and has(unit(QUANTITY))") -criteria["FULL_QUANTITY"][True] = lambda inputs: f"{inputs[0].name} has both value and unit.
Value: {inputs[0].value.content_string()}
Unit: ${inputs[0].unit_latex_string}$" -criteria["FULL_QUANTITY"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["NUMBER_VALUE"] = Criterion("is_number(value(QUANTITY))") -criteria["NUMBER_VALUE"][True] = no_feedback # lambda inputs: f"{inputs[0].name} value is a number: ${inputs[0].value_latex_string}$" -criteria["NUMBER_VALUE"][False] = no_feedback # lambda inputs: f"{inputs[0].name} value is not a number." - -criteria["EXPR_VALUE"] = Criterion("is_number(value(QUANTITY))") -criteria["EXPR_VALUE"][True] = lambda inputs: f"{inputs[0].name} value is an expression: ${inputs[0].value_latex_string}$" -criteria["EXPR_VALUE"][False] = lambda inputs: f"{inputs[0].name} value is not an expression." - -criteria["QUANTITY_MATCH"] = Criterion("QUANTITY matches QUANTITY", doc_string="Quantities match") -criteria["QUANTITY_MATCH"][True] = lambda inputs: f"${inputs[0].name}$ matches ${inputs[1].name}$" -criteria["QUANTITY_MATCH"][False] = lambda inputs: f"${inputs[0].name}$ does not match ${inputs[1].name}$" - -criteria["DIMENSION_MATCH"] = Criterion("dimension(QUANTITY) matches dimension(QUANTITY)", doc_string="Dimensions match") -criteria["DIMENSION_MATCH"][True] = no_feedback # lambda inputs: f"The {inputs[0].name} and {inputs[1].name} have the same dimensions." -criteria["DIMENSION_MATCH"][False] = lambda inputs: f"Dimensions of ${inputs[0].latex_string}$ does not match the dimensions of ${inputs[1].latex_string}$" - -criteria["MISSING_VALUE"] = Criterion("not(has(value(response))) and has(value(answer))", doc_string="Response is missing value when answer has value") -criteria["MISSING_VALUE"][True] = lambda inputs: "The response is missing a value." -criteria["MISSING_VALUE"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["MISSING_UNIT"] = Criterion("not(has(unit(response))) and has(unit(answer))", doc_string="Response is missing unit when answer has unit") -criteria["MISSING_UNIT"][True] = lambda inputs: "The response is missing unit(s)." -criteria["MISSING_UNIT"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["UNEXPECTED_VALUE"] = Criterion("has(value(response)) and not(has(value(answer)))", doc_string="Response has value when when answer has only unit") -criteria["UNEXPECTED_VALUE"][True] = lambda inputs: "The response is expected only have unit(s), no value." -criteria["UNEXPECTED_VALUE"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["UNEXPECTED_UNIT"] = Criterion("has(unit(response)) and not(has(unit(answer)))", doc_string="Response has unit when when answer has only value") -criteria["UNEXPECTED_UNIT"][True] = lambda inputs: "The response is expected to be a value without unit(s)." -criteria["UNEXPECTED_UNIT"][False] = no_feedback # Unknown how the condition has failed, no feedback in this case - -criteria["RESPONSE_MATCHES_ANSWER"] = Criterion("response matches answer", doc_string="Response matches answer") -criteria["RESPONSE_MATCHES_ANSWER"][True] = lambda inputs: f"${inputs[0].latex_string}$ matches the expected answer" -criteria["RESPONSE_MATCHES_ANSWER"][False] = lambda inputs: f"${inputs[0].latex_string}$ does not match the expected answer" - -criteria["RESPONSE_DIMENSION_MATCHES_ANSWER"] = Criterion("dimension(QUANTITY) matches dimension(QUANTITY)", doc_string="Dimensions match") -criteria["RESPONSE_DIMENSION_MATCHES_ANSWER"][True] = no_feedback # lambda inputs: f"The {inputs[0].name} and {inputs[1].name} have the same dimensions." -criteria["RESPONSE_DIMENSION_MATCHES_ANSWER"][False] = lambda inputs: f"Dimensions of ${inputs[0].latex_string}$ does not match the expected dimensions" - -criteria["RESPONSE_AND_ANSWER_HAS_UNITS"] = Criterion("has(unit(response)) and has(unit(answer))", doc_string="Both response and answer has a unit") -criteria["RESPONSE_AND_ANSWER_HAS_UNITS"][True] = no_feedback -criteria["RESPONSE_AND_ANSWER_HAS_UNITS"][False] = no_feedback - -criteria["PREFIX_IS_LARGE"] = Criterion("expanded_unit(response) >= 1000*expanded_unit(answer)", doc_string="The response prefix is much larger than the answer prefix") -criteria["PREFIX_IS_LARGE"][True] = lambda inputs: "The quantity can be written with fewer digits by using a smaller prefix." -criteria["PREFIX_IS_LARGE"][False] = no_feedback - -criteria["PREFIX_IS_SMALL"] = Criterion("expanded_unit(response)*1000 <= expanded_unit(answer)", doc_string="The response prefix is much smaller than the answer prefix") -criteria["PREFIX_IS_SMALL"][True] = lambda inputs: "The quantity can be written with fewer digits by using a larger prefix." -criteria["PREFIX_IS_SMALL"][False] = no_feedback - -internal = { - "REVERTED_UNIT": lambda before, content, after: "Possible ambiguity: `"+content+"` was not interpreted as a unit in
`"+before+"``"+content+"``"+after+"`" -} - -answer_matches_response_graph = CriteriaGraphContainer(criteria) -answer_matches_response_graph.attach("START", "MISSING_VALUE", result_map=flip_bool_result) -answer_matches_response_graph.finish("MISSING_VALUE", True) -answer_matches_response_graph.attach("MISSING_VALUE", "MISSING_UNIT", False, result_map=flip_bool_result) -answer_matches_response_graph.finish("MISSING_UNIT", True) -answer_matches_response_graph.attach("MISSING_UNIT", "UNEXPECTED_VALUE", False, result_map=flip_bool_result) -answer_matches_response_graph.finish("UNEXPECTED_VALUE", True) -answer_matches_response_graph.attach("UNEXPECTED_VALUE", "UNEXPECTED_UNIT", False, result_map=flip_bool_result) -answer_matches_response_graph.finish("UNEXPECTED_UNIT", True) -answer_matches_response_graph.attach("UNEXPECTED_UNIT", "RESPONSE_DIMENSION_MATCHES_ANSWER", False) -answer_matches_response_graph.attach("RESPONSE_DIMENSION_MATCHES_ANSWER", "RESPONSE_MATCHES_ANSWER", True) -answer_matches_response_graph.finish("RESPONSE_DIMENSION_MATCHES_ANSWER", False) -answer_matches_response_graph.attach("RESPONSE_MATCHES_ANSWER", "RESPONSE_AND_ANSWER_HAS_UNITS", True, override=False) -answer_matches_response_graph.finish("RESPONSE_MATCHES_ANSWER", False) -answer_matches_response_graph.attach("RESPONSE_AND_ANSWER_HAS_UNITS", "PREFIX_IS_LARGE", True, override=False) -answer_matches_response_graph.finish("RESPONSE_AND_ANSWER_HAS_UNITS", False) -answer_matches_response_graph.finish("PREFIX_IS_LARGE", True) -answer_matches_response_graph.attach("PREFIX_IS_LARGE", "PREFIX_IS_SMALL", False, override=False) -answer_matches_response_graph.finish("PREFIX_IS_SMALL", True) -answer_matches_response_graph.finish("PREFIX_IS_SMALL", False) - -if __name__ == "__main__": - generate_svg(answer_matches_response_graph.START, "app/docs/quantity_comparison_graph.svg", dummy_input=[DummyInput("response"), DummyInput("answer")]) diff --git a/evaluation_function/feedback/symbolic_comparison.py b/evaluation_function/feedback/symbolic_comparison.py deleted file mode 100644 index 1645608..0000000 --- a/evaluation_function/feedback/symbolic_comparison.py +++ /dev/null @@ -1,100 +0,0 @@ -from ..criteria_utilities import Criterion - - -# TODO: Find better way of identifying reference criteria -# equivalences dictionary should contain a list of variations that are likely to be produced by the following procedure: -# - rewrite critera as expr=0, -# - parse left hand side of rewritten critera as a sympy expression -# - turn sympy expression into a string and remove all whitespace -equivalences = dict() -criteria = dict() - -criteria["RESPONSE_DOUBLE_ANSWER"] = Criterion("response=2*answer") -equivalences.update({"RESPONSE_DOUBLE_ANSWER": ["response=2*answer", "response/answer=2", "2*answer=response", "answer=response/2", "answer-response/2", "-answer+response/2", "-2*answer+response", "2*answer-response", "-2+answer/response", "-2+response/answer", "answer-1*response/2", "-answer+1*response/2", "-2+1*answer/response", "-2+1*response/answer"]}) -criteria["RESPONSE_DOUBLE_ANSWER"][True] = lambda inputs: "The response is the expected answer multiplied by 2." -criteria["RESPONSE_DOUBLE_ANSWER"][False] = lambda inputs: "The response is not the expected answer multiplied by 2." - -criteria["RESPONSE_NEGATIVE_ANSWER"] = Criterion("response=-answer") -equivalences.update({"RESPONSE_NEGATIVE_ANSWER": ["response=-answer", "answer=-response", "answer+response=0", "answer+response", "answer/response=-1", "response/answer+1"]}) -criteria["RESPONSE_NEGATIVE_ANSWER"][True] = lambda inputs: "The response is the expected answer multiplied by -1." -criteria["RESPONSE_NEGATIVE_ANSWER"][False] = lambda inputs: "The response is not the expected answer multiplied by -1." - -# TODO: Handle multiple answer feedback properly -internal = { - "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": lambda name: f"Notation in {name} might be ambiguous, use `Abs(.)` instead of `|.|`", - "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_ANSWERS": "At least one answer is missing in the response.", - "PARSE_ERROR": lambda x: f"`{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.", - "EXPRESSION_NOT_EQUALITY": "The response was an expression but was expected to be an equality.", - "EQUALITY_NOT_EXPRESSION": "The response was an equality but was expected to be an expression.", - "WITHIN_TOLERANCE": "", # "The difference between the response the answer is within specified error tolerance.", - "NOT_NUMERICAL": "", # "The expression cannot be evaluated numerically.", -} - -# Format for feedback string entry: criteria["eval_tag"]("criteria_tag", inputs) = "formatted string" | None -criteria_equivalences = { - **{ - eq: "response=answer" for eq in [ - "answer=response", - "answer-response=0", - "-answer+response=0", - "answer/response=1", - "response/answer-1=0" - ] - } -} -feedback_generators = dict() -feedback_generators["EQUIVALENCES"] = criteria_equivalences -feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { - "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs['name']} might be ambiguous, use `Abs(.)` instead of `|.|`", - "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_ANSWERS": "At least one answer is missing in the response.", - "PARSE_ERROR": f"`{inputs['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.", - "EXPRESSION_NOT_EQUALITY": "The response was an expression but was expected to be an equality.", - "EQUALITY_NOT_EXPRESSION": "The response was an equality but was expected to be an expression.", - "WITHIN_TOLERANCE": None, # "The difference between the response the answer is within specified error tolerance.", - "NOT_NUMERICAL": None, # "The expression cannot be evaluated numerically.", -}[tag] -feedback_generators["GENERIC"] = lambda tag: lambda inputs: { - "TRUE": None, - "FALSE": f"{inputs['criterion'].content_string()} is false.", - "UNKNOWN": f"Cannot determine if {inputs['criterion'].content_string()} is true or false.", -}[tag] -feedback_generators["response=answer"] = lambda tag: lambda inputs: { - "TRUE": None, #"The response is equal to the expected answer.", - "FALSE": None, #"The response is not equal to the expected answer.", - "UNKNOWN": None, #"Cannot determine if answer is equal to response.", -}[tag] -feedback_generators["response=answer_where"] = lambda tag: lambda inputs: { - "TRUE": None, #"The response is equal to the expected value.", - "FALSE": None, #"The response is not equal to the expected value.", -}[tag] -feedback_generators["IDENTIFY_REASON"] = lambda tag: lambda inputs: { - "UNKNOWN": None, - "ONE_ADDITION_TO_SUBTRACTION": f"{inputs['criterion'].children[0].content_string()} if one addition is changed to a subtraction or vice versa.", - "ONE_EXPONENT_FLIP": f"{inputs['criterion'].children[0].content_string()} is true if one exponent has its sign changed.", - "ONE_SWAP_ADDITION_AND_MULTIPLICATION": f"{inputs['criterion'].children[0].content_string()} is true if one addition is replaced with a multiplication or vice versa.", -}[tag] -feedback_generators["GET_CANDIDATES"] = lambda tag: lambda inputs: None -feedback_generators["SYNTACTICAL_EQUIVALENCE"] = lambda tag: lambda inputs: { - "TRUE": None, - "FALSE": None, - "UNKNOWN": None, -}[tag] -feedback_generators["SAME_SYMBOLS"] = lambda tag: lambda inputs: { - "TRUE": None, - "FALSE": "The response can be simplified further.", -}[tag] -feedback_generators["SAME_FORM"] = lambda tag: lambda inputs: { - "CARTESIAN": "Response and answer are both written on Cartesian form.", # None, - "EXPONENTIAL": "Response and answer are both written on exponential form.", # None, - "UNKNOWN": "The response is not written on the expected form.", -}[tag] diff --git a/evaluation_function/main.py b/evaluation_function/main.py deleted file mode 100644 index 9375f86..0000000 --- a/evaluation_function/main.py +++ /dev/null @@ -1,18 +0,0 @@ - -from lf_toolkit import create_server, run - -from .evaluation import evaluation_function -from .preview import preview_function - -def main(): - """Run the IPC server with the evaluation and preview functions. - """ - server = create_server() - - server.eval(evaluation_function) - server.preview(preview_function) - - run(server) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/evaluation_function/preview.py b/evaluation_function/preview.py deleted file mode 100644 index 10450da..0000000 --- a/evaluation_function/preview.py +++ /dev/null @@ -1,48 +0,0 @@ -from .preview_utilities import ( - Params, - Preview, - Result, -) - -from .quantity_comparison_preview import preview_function as quantity_preview -from .symbolic_comparison_preview import preview_function as symbolic_comparison_preview - -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. - """ - - if params.get("text_prototype", False) is True: - response_original = response - if params.get("is_latex", False) is True: - latex_array_start = "\\begin{array}{l}\n" - latex_array_end = "\n\\end{array}" - latex_array_newline = "\\\\\n" - response = response.replace(latex_array_start, "") - response = response.replace(latex_array_end, "") - response = response.replace(latex_array_newline, " ") - result = Result(preview=Preview(latex=response_original, sympy=response)) - return result - - if params.get("physical_quantity", False): - result = quantity_preview(response, params) - else: - result = symbolic_comparison_preview(response, params) - - return result diff --git a/evaluation_function/preview_utilities.py b/evaluation_function/preview_utilities.py deleted file mode 100644 index c260bc9..0000000 --- a/evaluation_function/preview_utilities.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import TypedDict -from typing_extensions import NotRequired - -import sympy -from latex2sympy2 import latex2sympy - -from .expression_utilities import ( - extract_latex, - SymbolDict, - find_matching_parenthesis, -) - -class Params(TypedDict): - is_latex: bool - simplify: NotRequired[bool] - symbols: NotRequired[SymbolDict] - - -class Preview(TypedDict): - latex: str - sympy: str - feedback: str - - -class Result(TypedDict): - preview: Preview - - -def parse_latex(response: str, symbols: SymbolDict) -> str: - """Parse a LaTeX string to a sympy string while preserving custom symbols. - - Args: - response (str): The LaTeX expression to parse. - symbols (SymbolDict): A mapping of sympy symbol strings and LaTeX - symbol strings. - - Raises: - ValueError: If the LaTeX string or symbol couldn't be parsed. - - Returns: - str: The expression in sympy syntax. - """ - substitutions = {} - - for sympy_symbol_str in symbols: - symbol_str = symbols[sympy_symbol_str]["latex"] - latex_symbol_str = extract_latex(symbol_str) - - try: - latex_symbol = latex2sympy(latex_symbol_str) - except Exception: - raise ValueError( - f"Couldn't parse latex symbol {latex_symbol_str} " - f"to sympy symbol." - ) - - substitutions[latex_symbol] = sympy.Symbol(sympy_symbol_str) - - try: - expression = latex2sympy(response, substitutions) - - if isinstance(expression, list): - expression = expression.pop() - - return str(expression.xreplace(substitutions)) # type: ignore - - except Exception as e: - raise ValueError(str(e)) - -def sanitise_latex(response): - response = "".join(response.split()) - response = response.replace('~',' ') - wrappers = [r"\mathrm",r"\text"] - for wrapper in wrappers: - processed_response = [] - index = 0 - while index < len(response): - wrapper_start = response.find(wrapper+"{", index) - if wrapper_start > -1: - processed_response.append(response[index:wrapper_start]) - wrapper_end = find_matching_parenthesis(response, wrapper_start+1, delimiters=('{','}')) - inside_wrapper = response[(wrapper_start+len(wrapper+"{")):wrapper_end] - processed_response.append(inside_wrapper) - index = wrapper_end+1 - else: - processed_response.append(response[index:]) - index = len(response) - response = "".join(processed_response) - return response \ No newline at end of file diff --git a/evaluation_function/symbolic_comparison_evaluation.py b/evaluation_function/symbolic_comparison_evaluation.py deleted file mode 100644 index f9a5ea0..0000000 --- a/evaluation_function/symbolic_comparison_evaluation.py +++ /dev/null @@ -1,847 +0,0 @@ -from sympy.parsing.sympy_parser import T as parser_transformations -from sympy import Abs, Equality, latex, pi, Symbol, Add, Pow, Mul, N -from sympy.core.function import UndefinedFunction -from sympy.printing.latex import LatexPrinter -from copy import deepcopy -import re - -from .expression_utilities import ( - substitute_input_symbols, - parse_expression, - create_sympy_parsing_params, - create_expression_set, - convert_absolute_notation, - latex_symbols, -) - -from .slr_parsing_utilities import SLR_Parser, catch_undefined, infix, create_node, operate, join, group, proceed, append_last - -from .evaluation_response_utilities import EvaluationResponse -from .feedback.symbolic_comparison import internal as symbolic_comparison_internal_messages -from .feedback.symbolic_comparison import criteria as symbolic_comparison_criteria -from .feedback.symbolic_comparison import feedback_generators as symbolic_feedback_generators -from .feedback.symbolic_comparison import equivalences as reference_criteria_strings - -from .syntactical_comparison_utilities import patterns as syntactical_forms -from .syntactical_comparison_utilities import is_number as syntactical_is_number -from .syntactical_comparison_utilities import response_and_answer_on_same_form -from .syntactical_comparison_utilities import attach_form_criteria - -from .criteria_graph_utilities import CriteriaGraph - -criteria_operations = { - "not": lambda x, p: not check_criterion(x[0], p, generate_feedback=False), -} - -def generate_criteria_parser(): - start_symbol = "START" - end_symbol = "END" - null_symbol = "NULL" - - token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" *BOOL *", "BOOL"), - (" *\* *", "PRODUCT"), - (" */ *", "DIVISION"), - (" *\+ *", "PLUS"), - (" *- *", "MINUS"), - (" *= *", "EQUALITY"), - ("\( *", "START_DELIMITER"), - (" *\)", "END_DELIMITER"), - (" *; *", "SEPARATOR"), - ("response", "EXPR"), - (" *where *", "WHERE"), - ("answer", "EXPR"), - ("EQUAL", "EQUAL"), - ("EQUALS", "EQUALS"), - ("EXPR", "EXPR", catch_undefined), - ] - token_list += [(" *"+x+" *", " "+x+" ") for x in criteria_operations.keys()] - - productions = [ - ("START", "BOOL", create_node), - ("BOOL", "not(BOOL)", operate(1)), - ("BOOL", "EQUAL", proceed), - ("EQUAL", "EQUAL where EQUAL", infix), - ("EQUAL", "EQUAL where EQUALS", infix), - ("EQUALS", "EQUAL;EQUAL", infix), - ("EQUALS", "EQUALS;EQUAL", append_last), - ("EQUAL", "EXPR=EXPR", infix), - ("EXPR", "-EXPR", join), - ("EXPR", "EXPR-EXPR", infix), - ("EXPR", "EXPR+EXPR", infix), - ("EXPR", "EXPR*EXPR", infix), - ("EXPR", "EXPREXPR", join), - ("EXPR", "EXPR/EXPR", infix), - ("EXPR", "(EXPR)", join), - ] - - return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) - -def check_criterion(criterion, parameters_dict, generate_feedback=True): - label = criterion.label.strip() - parsing_params = parameters_dict["parsing_params"] - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - local_substitutions = parameters_dict.get("local_substitutions",[]) - reference_criteria_strings = parameters_dict["reference_criteria_strings"] - eval_response = parameters_dict["eval_response"] - parsing_params = {key: value for (key,value) in parameters_dict["parsing_params"].items()} - parsing_params.update({"simplify": False}) - symbolic_comparison_criteria = parameters_dict["symbolic_comparison_criteria"] - if label == "EQUALITY": - result = check_equality(criterion, parameters_dict) - lhs = criterion.children[0].content_string() - rhs = criterion.children[1].content_string() - criterion_expression = (parse_expression(lhs, parsing_params)) - (parse_expression(rhs, parsing_params)) - for (reference_tag, reference_strings) in reference_criteria_strings.items(): - if reference_tag in eval_response.get_tags(): - continue - if "".join(str(criterion_expression).split()) in reference_strings and generate_feedback is True: - feedback = symbolic_comparison_criteria[reference_tag].feedback[result]([]) - eval_response.add_feedback((reference_tag, feedback)) - break - elif label == "WHERE": - crit = criterion.children[0] - subs = criterion.children[1] - local_subs = [] - if subs.label == "EQUALITY": - subs = [subs] - elif subs.label == "SEPARATOR": - subs = subs.children - for sub in subs: - name = sub.children[0].content_string() - expr = parse_expression(sub.children[1].content_string(), parsing_params) - local_subs.append((name, expr)) - result = check_criterion(crit, {**parameters_dict, **{"local_substitutions": local_subs}}, generate_feedback) - elif label in criteria_operations.keys(): - result = criteria_operations[label](criterion.children, parameters_dict) - return result - -def check_equality(criterion, parameters_dict): - - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - local_substitutions = parameters_dict.get("local_substitutions",[]) - parsing_params = {key: value for (key,value) in parameters_dict["parsing_params"].items()} - parsing_params.update({"simplify": False}) - - #Define atol and rtol - - #Gets the LHS and RHS of the equation - - lhs = criterion.children[0].content_string() - rhs = criterion.children[1].content_string() - - #LHS is response - #RHS is answer - - #Parses into a mathematical expression - the numerical value needs to be extracted - expression = (parse_expression(lhs, parsing_params)) - (parse_expression(rhs, parsing_params)) - result = bool(expression.subs(reserved_expressions).subs(local_substitutions).cancel().simplify().simplify() == 0) - - if result is False: - error_below_rtol = None - error_below_atol = None - if parameters_dict.get("numerical", False) or float(parameters_dict.get("rtol", 0)) > 0 or float(parameters_dict.get("atol", 0)) > 0: - - # REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols. - # The two lines below this comments fixes the issue but a more robust solution should be found for cases where there - # are other reserved symbols. - def replace_pi(expr): - pi_symbol = pi - for s in expr.free_symbols: - if str(s) == 'pi': - pi_symbol = s - return expr.subs(pi_symbol, float(pi)) - - # NOTE: This code assumes that the left hand side is the response and the right hand side is the answer - # Separates LHS and RHS, parses and evaluates them - res = N(replace_pi(parse_expression(lhs, parsing_params).subs(reserved_expressions).subs(local_substitutions))) - ans = N(replace_pi(parse_expression(rhs, parsing_params).subs(reserved_expressions).subs(local_substitutions))) - - if float(parameters_dict.get("atol", 0)) > 0: - try: - absolute_error = abs(float(ans-res)) - error_below_atol = bool(absolute_error < float(parameters_dict["atol"])) - except TypeError: - error_below_atol = None - else: - error_below_atol = True - if float(parameters_dict.get("rtol", 0)) > 0: - try: - relative_error = abs(float((ans-res)/ans)) - error_below_rtol = bool(relative_error < float(parameters_dict["rtol"])) - except TypeError: - error_below_rtol = None - else: - error_below_rtol = True - if error_below_atol is None or error_below_rtol is None: - result = False - # TODO: The code below for supplying the right tag will be moved elsewhere in the code in the future - """ - eval_response.is_correct = False - tag = "NOT_NUMERICAL" - eval_response.add_feedback((tag, symbolic_comparison_internal_messages[tag])) - """ - elif error_below_atol is True and error_below_rtol is True: - result = True - # TODO: The code below for supplying the right tag will be moved elsewhere in the code in the future - """ - eval_response.is_correct = True - tag = "WITHIN_TOLERANCE" - eval_response.add_feedback((tag, symbolic_comparison_internal_messages[tag])) - """ - - - return result - -def criterion_eval_node(criterion, parameters_dict, generate_feedback=True): - def evaluation_node_internal(unused_input): - result = check_criterion(criterion, parameters_dict, generate_feedback) - label = criterion.content_string() - if result: - return {label+"_TRUE"} - else: - return {label+"_FALSE"} - label = criterion.content_string() - graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) - graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) - graph.attach( - label, - label+"_TRUE", - summary="True", - details=label+" is true.", - feedback_string_generator=symbolic_feedback_generators["GENERIC"]("TRUE") - ) - graph.attach(label+"_TRUE", END.label) - graph.attach( - label, - label+"_FALSE", - summary="True", - details=label+" is false.", - feedback_string_generator=symbolic_feedback_generators["GENERIC"]("FALSE") - ) - graph.attach(label+"_FALSE", END.label) - return graph - - -#Not the issue -def criterion_equality_node(criterion, parameters_dict, label=None): - if label is None: - label = criterion.content_string() - - def mathematical_equivalence(unused_input): - result = check_equality(criterion, parameters_dict) - if result is True: - return {label+"_TRUE"} - else: - return {label+"_FALSE"} - graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) - lhs = criterion.children[0].content_string() - rhs = criterion.children[1].content_string() - - def syntactical_equivalence(unused_input): - result = parameters_dict["original_input"]["answer"] == parameters_dict["original_input"]["response"] - if result is True: - return {label+"_SYNTACTICAL_EQUIVALENCE"+"_TRUE"} - else: - return {label+"_SYNTACTICAL_EQUIVALENCE"+"_FALSE"} - - def same_symbols(unused_input): - local_substitutions = parameters_dict.get("local_substitutions",[]) - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = {key: value for (key,value) in parameters_dict["parsing_params"].items()} - parsing_params.update({"simplify": False}) - for k, item in enumerate(reserved_expressions): - if item[0] == "answer": - reserved_expressions[k] = ("answer", parameters_dict["reserved_expressions"]["answer_original"]) - elif item[0] == "response": - reserved_expressions[k] = ("response", parameters_dict["reserved_expressions"]["response_original"]) - lsym = parse_expression(lhs, parsing_params).subs(reserved_expressions).subs(local_substitutions) - rsym = parse_expression(rhs, parsing_params).subs(reserved_expressions).subs(local_substitutions) - result = lsym.free_symbols == rsym.free_symbols - if result is True: - return {label+"_SAME_SYMBOLS"+"_TRUE"} - else: - return {label+"_SAME_SYMBOLS"+"_FALSE"} - - # Check for mathematical equivalence - graph.add_evaluation_node( - label, - summary=label, - details="Checks if "+str(lhs)+"="+str(rhs)+".", - evaluate=mathematical_equivalence - ) - graph.attach( - label, - label+"_TRUE", - summary=str(lhs)+"="+str(rhs), - details=str(lhs)+" is equal to "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_generators["response=answer"]("TRUE") - ) - - graph.attach( - 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( - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_TRUE", - summary=str(lhs)+" has the same symbols as "+str(rhs), - details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_generators["SAME_SYMBOLS"]("TRUE") - ) - graph.attach(label+"_SAME_SYMBOLS"+"_TRUE", END.label) - graph.attach( - label+"_SAME_SYMBOLS", - label+"_SAME_SYMBOLS"+"_FALSE", - summary=str(lhs)+" does not have the same symbols as "+str(rhs), - details=str(lhs)+" does note have the same (free) symbols as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_generators["SAME_SYMBOLS"]("FALSE") - ) - graph.attach(label+"_SAME_SYMBOLS"+"_FALSE", END.label) - - graph.attach( - label, - label+"_FALSE", - summary=str(lhs)+"=\\="+str(rhs), - details=str(lhs)+" is not equal to"+str(rhs)+".", - feedback_string_generator=symbolic_feedback_generators["response=answer"]("FALSE") - ) - - if parameters_dict["syntactical_comparison"] is True: - if set([lhs, rhs]) == set(["response", "answer"]): - has_recognisable_form = syntactical_is_number(parameters_dict["original_input"]["answer"]) - for form_label in syntactical_forms.keys(): - has_recognisable_form = has_recognisable_form or syntactical_forms[form_label]["matcher"](parameters_dict["original_input"]["answer"]) - if has_recognisable_form is True: - - graph.attach( - label+"_TRUE", - label+"_SYNTACTICAL_EQUIVALENCE", - summary="response is written like answer", - details="Checks if "+str(lhs)+" is written exactly the same as "+str(rhs)+".", - evaluate=syntactical_equivalence - ) - graph.attach( - label+"_SYNTACTICAL_EQUIVALENCE", - label+"_SYNTACTICAL_EQUIVALENCE"+"_TRUE", - summary="response is written like answer", - details=""+str(lhs)+" is written exactly the same as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_generators["SYNTACTICAL_EQUIVALENCE"]("TRUE") - ) - graph.attach( - label+"_SYNTACTICAL_EQUIVALENCE"+"_TRUE", - END.label - ) - graph.attach( - label+"_SYNTACTICAL_EQUIVALENCE", - label+"_SYNTACTICAL_EQUIVALENCE"+"_FALSE", - summary="response is not written like answer", details=""+str(lhs)+" is not written exactly the same as "+str(rhs)+".", - feedback_string_generator=symbolic_feedback_generators["SYNTACTICAL_EQUIVALENCE"]("FALSE") - ) - graph.attach(label+"_SYNTACTICAL_EQUIVALENCE"+"_FALSE", END.label) - - graph.attach( - label+"_TRUE", - label+"_SAME_FORM", - summary=str(lhs)+" is written in the same form as "+str(rhs), - details=str(lhs)+" is written in the same form as "+str(rhs)+".", - evaluate=response_and_answer_on_same_form(label+"_SAME_FORM", parameters_dict) - ) - - for form_label in syntactical_forms.keys(): - if syntactical_forms[form_label]["matcher"](parameters_dict["original_input"]["answer"]) is True: - attach_form_criteria(graph, label+"_SAME_FORM", criterion, parameters_dict, form_label) - - graph.attach( - label+"_SAME_FORM", - label+"_SAME_FORM"+"_UNKNOWN", - summary="Cannot determine if "+str(lhs)+" and "+str(rhs)+" are written on the same form", - details="Cannot determine if "+str(lhs)+" and "+str(rhs)+" are written on the same form.", - feedback_string_generator=symbolic_feedback_generators["SAME_FORM"]("UNKNOWN"), - ) - - graph.attach(label+"_SAME_FORM"+"_UNKNOWN", END.label) - - graph.attach(label+"_FALSE", label+"_SAME_FORM") - else: - graph.attach(label+"_FALSE", END.label) - return graph - -def find_coords_for_node_type(expression, node_type): - stack = [(expression, tuple() )] - node_coords = [] - while len(stack) > 0: - (expr, coord) = stack.pop() - if isinstance(expr, node_type): - node_coords.append(coord) - for (k, arg) in enumerate(expr.args): - stack.append((arg, coord+(k,))) - return node_coords - -def replace_node_variations(expression, type_of_node, replacement_function): - variations = [] - list_of_coords = find_coords_for_node_type(expression, type_of_node) - for coords in list_of_coords: - nodes = [expression] - for coord in coords: - nodes.append(nodes[-1].args[coord]) - for k in range(0, len(nodes[-1].args)): - variation = replacement_function(nodes[-1], k) - for (node, coord) in reversed(list(zip(nodes, coords))): - new_args = node.args[0:coord]+(variation,)+node.args[coord+1:] - variation = type(node)(*new_args) - variations.append(variation) - return variations - -def one_addition_to_subtraction(expression): - def addition_to_subtraction(node, k): - return node - 2*node.args[k] - variations = replace_node_variations(expression, Add, addition_to_subtraction) - return variations - -def one_swap_addition_and_multiplication(expression): - def addition_to_multiplication(node, k): - return node - node.args[k-1] - node.args[k] + node.args[k-1] * node.args[k] - def multiplication_to_addition(node, k): - return node - 2*node.args[k] - variations = replace_node_variations(expression, Add, addition_to_multiplication) - variations += replace_node_variations(expression, Mul, addition_to_multiplication) - return variations - -def one_exponent_flip(expression): - def exponent_flip(node, k): - return node**(-1) - variations = replace_node_variations(expression, Pow, exponent_flip) - return variations - -#3rd in chain: returns part of the criterion graph - -def criterion_where_node(criterion, parameters_dict, label=None): - parsing_params = parameters_dict["parsing_params"] - expression = criterion.children[0] - subs = criterion.children[1] - local_subs = [] - if subs.label == "EQUALITY": - subs = [subs] - elif subs.label == "SEPARATOR": - subs = subs.children - for sub in subs: - name = sub.children[0].content_string() - expr = parse_expression(sub.children[1].content_string(), parsing_params) - local_subs.append((name, expr)) - if label is None: - label = criterion.content_string() - local_parameters = {**parameters_dict} - if "local_substitutions" in local_parameters.keys(): - local_parameters["local_substitutions"] += local_subs - else: - local_parameters.update({"local_substitutions": local_subs}) - def create_expression_check(crit): - def expression_check(unused_input): - result = check_equality(crit, local_parameters) - if result is True: - return {label+"_TRUE"} - else: - return {label+"_FALSE"} - return expression_check - - graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) - 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( - label, - label+"_TRUE", - summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), - details=expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+"is true.", - feedback_string_generator=symbolic_feedback_generators["response=answer_where"]("TRUE") - ) - graph.attach(label+"_TRUE", END.label) - graph.attach( - label, - label+"_FALSE", - summary="not "+expression.content_string(), - details=expression.content_string()+" is not true when "+", ".join([s.content_string() for s in subs])+".", - feedback_string_generator=symbolic_feedback_generators["response=answer_where"]("FALSE") - ) - - reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - response = parameters_dict["reserved_expressions"]["response"] - expression_to_vary = None - if "response" in expression.children[0].content_string().strip(): - expression_to_vary = expression.children[1] - elif "response" in expression.children[1].content_string().strip(): - expression_to_vary = expression.children[0] - if expression_to_vary is not None and "response" in expression_to_vary.content_string(): - expression_to_vary = None - if expression_to_vary is not None: - response_value = response.subs(local_subs) - expression_to_vary = parse_expression(expression_to_vary.content_string(), parsing_params).subs(reserved_expressions) - variation_groups = { - "ONE_ADDITION_TO_SUBTRACTION": { - "variations": one_addition_to_subtraction(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" if one addition is changed to a subtraction or vice versa.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), - }, - "ONE_EXPONENT_FLIP": { - "variations": one_exponent_flip(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one exponent has its sign changed.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), - }, - "ONE_SWAP_ADDITION_AND_MULTIPLICATION": { - "variations": one_swap_addition_and_multiplication(expression_to_vary), - "summary": lambda expression, variations: criterion.children[0].content_string()+" is true if one addition is replaced with a multiplication or vice versa.", - "details": lambda expression, variations: "The following expressions are checked: "+", ".join([str(e) for e in variations]), - } - } - reference_value = expression_to_vary.subs(local_subs) - values_and_expressions = {reference_value: set([expression_to_vary])} - values_and_variations_group = {reference_value: set(["UNDETECTABLE"])} - undetectable_variations = set() - for (group_label, info) in variation_groups.items(): - for variation in info["variations"]: - value = variation.subs(local_subs) - values_and_expressions.update({value: values_and_expressions.get(value, set()).union(set([variation]))}) - if value == reference_value: - undetectable_variations.add(variation) - else: - values_and_variations_group.update({value: values_and_variations_group.get(value, set()).union(set([group_label]))}) - if len(values_and_expressions) > 1: - def identify_reason(unused_input): - reasons = {label+"_"+group_label for group_label in values_and_variations_group.get(response_value, {"UNKNOWN"})} - return reasons - graph.attach( - label+"_FALSE", - label+"_IDENTIFY_REASON", - summary="Identify reason.", - details="Attempt to identify why the response is incorrect.", - evaluate=identify_reason - ) - graph.attach( - label+"_IDENTIFY_REASON", - label+"_UNKNOWN", - summary="Unknown reason", - details="No candidates for how the response was computed were found.", - feedback_string_generator=symbolic_feedback_generators["IDENTIFY_REASON"]("UNKNOWN") - ) - graph.attach(label+"_UNKNOWN", END.label) - - def get_candidates(unused_input): - candidates = set(["response candidates "+", ".join([str(e) for e in values_and_expressions[response_value]])]) - return candidates - for (group_label, group_info) in variation_groups.items(): - graph.attach( - 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_generators["IDENTIFY_REASON"]("UNKNOWN") - ) - graph.attach( - label+"_"+group_label, - label+"_GET_CANDIDATES_"+group_label, - summary="Get candidate responses that satisfy "+expression.content_string(), - details="Get candidate responses that satisfy "+expression.content_string(), - evaluate=get_candidates - ) - - for (value, expressions) in values_and_expressions.items(): - expressions_string = ", ".join([str(e) for e in expressions]) - for group_label in values_and_variations_group[value]: - if group_label != "UNDETECTABLE": - graph.attach( - label+"_GET_CANDIDATES_"+group_label, - "response candidates "+expressions_string, - summary="response = "+str(value), - details="Response candidates: "+expressions_string - ) - return graph - -def create_criteria_dict(criteria_string, criteria_parser, parsing_params): - criteria_string_list = [] - delims = [ - ("(", ")"), - ("[", "]"), - ("{", "}"), - ] - depth = {delim: 0 for delim in delims} - delim_key = {delim[0]: delim for delim in delims} - delim_key.update({delim[1]: delim for delim in delims}) - criterion_start = 0 - for n, c in enumerate(criteria_string): - if c in [delim[0] for delim in delims]: - depth[delim_key[c]] += 1 - if c in [delim[1] for delim in delims]: - depth[delim_key[c]] -= 1 - if c == "," and all([d == 0 for d in depth.values()]): - criteria_string_list.append(criteria_string[criterion_start:n].strip()) - criterion_start = n+1 - criteria_string_list.append(criteria_string[criterion_start:].strip()) - criteria_parsed = dict() - for criterion in criteria_string_list: - try: - criterion_tokens = criteria_parser.scan(criterion) - criterion_parsed = criteria_parser.parse(criterion_tokens)[0] - criteria_parsed.update({criterion_parsed.content_string(): criterion_parsed}) - except Exception as e: - print(e) - raise Exception("Cannot parse criteria: `"+criterion+"`.") from e - return criteria_parsed - - -# 2nd in the chain - Creates the evaluation criteria - -def create_criteria_graphs(criteria, params_dict): - criteria_graphs = {} - graph_templates = { - "EQUALITY": criterion_equality_node, - "WHERE": criterion_where_node - } - - for (label, criterion) in criteria.items(): - graph_template = graph_templates.get(criterion.label, criterion_eval_node) - graph = graph_template(criterion, params_dict) - for evaluation in graph.evaluations.values(): - if evaluation.label in params_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END - criteria_graphs.update({label: graph}) - return criteria_graphs - -""" -The main function -""" -def evaluation_function(response, answer, params, include_test_data=False) -> dict: - """ - Function used to symbolically compare two expressions. - """ - - eval_response = EvaluationResponse() #Initiayion - eval_response.is_correct = False - - # This code handles the plus_minus and minus_plus operators - # actual symbolic comparison is done in *check_equality* - if "multiple_answers_criteria" not in params.keys(): - params.update({"multiple_answers_criteria": "all"}) - - response_list = create_expression_set(response, params) - answer_list = create_expression_set(answer, params) - - if len(response_list) == 1 and len(answer_list) == 1: - eval_response = symbolic_comparison(response, answer, params, eval_response) - else: - matches = {"responses": [False]*len(response_list), "answers": [False]*len(answer_list)} - interp = [] - for i, response in enumerate(response_list): - result = None - for j, answer in enumerate(answer_list): - result = symbolic_comparison(response, answer, params, eval_response) - if result["is_correct"]: - matches["responses"][i] = True - matches["answers"][j] = True - if len(interp) == 0: - interp = result["response_latex"] - interp_sympy = result["response_simplified"] - else: - interp += result["response_latex"] - interp_sympy += ", " + result["response_simplified"] - if params["multiple_answers_criteria"] == "all": - is_correct = all(matches["responses"]) and all(matches["answers"]) - if is_correct is False: - eval_response.add_feedback(("MULTIPLE_ANSWER_FAIL_ALL", symbolic_comparison_internal_messages["MULTIPLE_ANSWER_FAIL_ALL"])) - elif params["multiple_answers_criteria"] == "all_responses": - is_correct = all(matches["responses"]) - if is_correct is False: - eval_response.add_feedback(("MULTIPLE_ANSWER_FAIL_RESPONSE", symbolic_comparison_internal_messages["MULTIPLE_ANSWER_FAIL_RESPONSE"])) - elif params["multiple_answers_criteria"] == "all_answers": - is_correct = all(matches["answers"]) - if is_correct is False: - eval_response.add_feedback(("MULTIPLE_ANSWER_FAIL_RESPONSE", symbolic_comparison_internal_messages["MULTIPLE_ANSWER_FAIL_ANSWERS"])) - else: - raise SyntaxWarning(f"Unknown multiple_answers_criteria: {params['multiple_answers_critera']}") - eval_response.is_correct = is_correct - if len(interp) > 1: - response_latex = "\\left\\{"+",".join(interp)+"\\right\\}" - else: - response_latex = interp - eval_response.latex = response_latex - - return eval_response - -#1st in chain, executes the comparison. Returns the eval response, which includes the T/F and additional info. Evalresponse is a class -def symbolic_comparison(response, answer, params, eval_response) -> dict: - - #Error exceptions - - - if not isinstance(answer, str): - raise Exception("No answer was given.") - if not isinstance(response, str): - eval_response.is_correct = False - eval_response.add_feedback(("NO_RESPONSE", symbolic_comparison_internal_messages["NO_RESPONSE"])) - return eval_response - - answer = answer.strip() - response = response.strip() - if len(answer) == 0: - raise Exception("No answer was given.") - if len(response) == 0: - eval_response.is_correct = False - eval_response.add_feedback(("NO_RESPONSE", symbolic_comparison_internal_messages["NO_RESPONSE"])) - return eval_response - - - # Makes everything symbolic - - answer, response = substitute_input_symbols([answer, response], params) - parsing_params = create_sympy_parsing_params(params) - parsing_params.update({"rationalise": True, "simplify": True}) - parsing_params["extra_transformations"] = parser_transformations[9] # Add conversion of equal signs - - # Converting absolute value notation to a form that SymPy accepts - response, response_feedback = convert_absolute_notation(response, "response") - if response_feedback is not None: - eval_response.add_feedback(response_feedback) - answer, answer_feedback = convert_absolute_notation(answer, "answer") - if answer_feedback is not None: - raise SyntaxWarning(answer_feedback[1], answer_feedback[0]) - - if params.get("strict_syntax", True): - if "^" in response: - eval_response.add_feedback(("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages["NOTATION_WARNING_EXPONENT"])) - if "!" in response: - eval_response.add_feedback(("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages["NOTATION_WARNING_FACTORIAL"])) - - # Safely try to parse answer and response into symbolic expressions - parsing_params_original = {**parsing_params} - parsing_params_original.update({"rationalise": False, "simplify": False}) - try: - res = parse_expression(response, parsing_params) - res_original = parse_expression(response, parsing_params_original) - except Exception as e: - eval_response.is_correct = False - eval_response.add_feedback(("PARSE_ERROR", symbolic_comparison_internal_messages["PARSE_ERROR"](response))) - return eval_response - - try: - ans = parse_expression(answer, parsing_params) - ans_original = parse_expression(answer, parsing_params_original) - except Exception as e: - raise Exception(f"SymPy was unable to parse the answer: {answer}.") from e - - # Convert parsed_response into LaTeX. - # Symbols that denote undefined functions are replaced with placeholders since these symbols causes issues with printing - symbols = params.get("symbols", {}) - printing_symbols = dict() - for key in parsing_params["symbol_dict"].keys(): - if key in symbols.keys(): - printing_symbols.update({key: symbols[key]["latex"]}) - printing_params = {**params} - if "symbol_assumptions" in printing_params.keys(): - del printing_params["symbol_assumptions"] - if "=" in response: - response_parts = response.split("=") - lhs_print = parse_expression(response_parts[0], create_sympy_parsing_params(printing_params)) - rhs_print = parse_expression(response_parts[1], create_sympy_parsing_params(printing_params)) - res_print = Equality(lhs_print, rhs_print) - else: - res_print = parse_expression(response, create_sympy_parsing_params(printing_params)) - eval_response.latex = LatexPrinter({"symbol_names": printing_symbols, "mul_symbol": r" \cdot "}).doprint(res_print) - eval_response.simplified = str(res) - - if (not isinstance(res_original, Equality)) and isinstance(ans_original, Equality): - eval_response.is_correct = False - tag = "EXPRESSION_NOT_EQUALITY" - eval_response.add_feedback((tag, symbolic_comparison_internal_messages[tag])) - return eval_response - - if isinstance(res_original, Equality) and (not isinstance(ans_original, Equality)): - eval_response.is_correct = False - tag = "EQUALITY_NOT_EXPRESSION" - eval_response.add_feedback((tag, symbolic_comparison_internal_messages[tag])) - return eval_response - - # TODO: Remove when criteria for checking proportionality is implemented - if isinstance(res_original, Equality) and isinstance(ans_original, Equality): - symbols_in_equality_ratio = ((res_original.args[0]-res_original.args[1])/(ans_original.args[0]-ans_original.args[1])).simplify().free_symbols - eval_response.is_correct = {str(s) for s in symbols_in_equality_ratio}.issubset(parsing_params["constants"]) - return eval_response - - # Parse criteria - criteria_parser = generate_criteria_parser() - parsing_params["unsplittable_symbols"] += ("response", "answer", "where") - reserved_expressions = { - "response": res, - "answer": ans, - "response_original": res_original, - "answer_original": ans_original, - } - criteria_string = substitute_input_symbols(params.get("criteria", "response=answer"), params)[0] - criteria_parsed = create_criteria_dict(criteria_string, criteria_parser, parsing_params) - - - # Criteria graphs are the reference - - - # Create criteria graphs - is_correct = True - parameters_dict = { - "parsing_params": parsing_params, - "reserved_expressions": reserved_expressions, - "reference_criteria_strings": reference_criteria_strings, - "symbolic_comparison_criteria": symbolic_comparison_criteria, - "eval_response": eval_response, - "original_input": {"answer": answer, "response": response}, - "disabled_evaluation_nodes": params.get("disabled_evaluation_nodes", set()), - "syntactical_comparison": params.get("syntactical_comparison", False), - "atol": (params.get("atol", 0)), - "rtol": (params.get("rtol", 0)), - "numerical": params.get("numerical", False), - - } - criteria_graphs = create_criteria_graphs(criteria_parsed, parameters_dict) - - # Generate feedback from criteria graphs - criteria_feedback = set() - for (criterion_identifier, graph) in criteria_graphs.items(): - # TODO: Find better way to identify main criteria for criteria graph - main_criteria = criterion_identifier+"_TRUE" - criteria_feedback = criteria_feedback.union(graph.generate_feedback(response, main_criteria)) - - # TODO: Implement way to define completeness of task other than "all main criteria satisfied" - is_correct = is_correct and main_criteria in criteria_feedback - eval_response.add_criteria_graph(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 - eval_response.add_feedback_from_tags(criteria_feedback, graph, {"criterion": criteria_parsed[criterion_identifier]}) - result = main_criteria in criteria_feedback - for item in criteria_feedback: - eval_response.add_feedback((item, "")) - for (reference_tag, reference_strings) in reference_criteria_strings.items(): - if reference_tag in eval_response.get_tags(): - continue - if "".join(criterion_identifier.split()) in reference_strings: - feedback = symbolic_comparison_criteria[reference_tag].feedback[result]([]) - eval_response.add_feedback((reference_tag, feedback)) - break - eval_response.is_correct = is_correct - - return eval_response diff --git a/evaluation_function/syntactical_comparison_utilities.py b/evaluation_function/syntactical_comparison_utilities.py deleted file mode 100644 index 39cade3..0000000 --- a/evaluation_function/syntactical_comparison_utilities.py +++ /dev/null @@ -1,59 +0,0 @@ -import re -from .feedback.symbolic_comparison import feedback_generators as symbolic_feedback_generators -from .criteria_graph_utilities import CriteriaGraph - -is_number_regex = '(-?(0|[1-9]\d*)?(\.\d+)?(?<=\d)(e-?(0|[1-9]\d*))?)' - -def is_number(string): - match_content = re.fullmatch(is_number_regex, string) - return match_content is not None and len(match_content.group(0)) > 0 - -def is_complex_number_on_cartesian_form(string): - string = "".join(string.split()) - result = re.fullmatch(is_number_regex+"?\+?"+is_number_regex+"?\*?I?", string) - return result is not None - -def is_complex_number_on_exponential_form(string): - string = "".join(string.split()) - result = re.fullmatch(is_number_regex+"?\*?(E\^|E\*\*|exp)\(?"+is_number_regex+"*\*?I\)?", string) - return result is not None - - -patterns = { - "CARTESIAN": { - "matcher": is_complex_number_on_cartesian_form, - "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written on cartesian form", - "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written on cartesian form, i.e. $a+bi$.", - }, - "EXPONENTIAL": { - "matcher": is_complex_number_on_exponential_form, - "summary": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written on exponential form", - "details": lambda criterion, parameters_dict: str(criterion.children[0].content_string())+" and "+str(criterion.children[1].content_string())+" are both complex numbers written on exponential form, i.e. $a exp(bi)$.", - }, -} - - -def attach_form_criteria(graph, attachment_node, criterion, parameters_dict, form_label): - graph.attach( - attachment_node, - attachment_node+"_"+form_label, - summary=patterns[form_label]["summary"](criterion, parameters_dict), - details=patterns[form_label]["details"](criterion, parameters_dict), - feedback_string_generator=symbolic_feedback_generators["SAME_FORM"](form_label), - ) - graph.attach(attachment_node+"_"+form_label, CriteriaGraph.END.label) - - -def response_and_answer_on_same_form(label, parameters_dict): - local_answer = parameters_dict["original_input"]["answer"] - local_response = parameters_dict["original_input"]["response"] - matches_found = set() - - def inner(unused_input): - for form_label in patterns.keys(): - if patterns[form_label]["matcher"](local_answer) and patterns[form_label]["matcher"](local_response): - matches_found.add(label+"_"+form_label) - if len(matches_found) == 0: - matches_found.add(label+"_UNKNOWN") - return matches_found - return inner diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 7695ce5..0000000 --- a/poetry.lock +++ /dev/null @@ -1,643 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "antlr4-python3-runtime" -version = "4.7.2" -description = "ANTLR 4.7.2 runtime for Python 3.6.3" -optional = false -python-versions = "*" -files = [ - {file = "antlr4-python3-runtime-4.7.2.tar.gz", hash = "sha256:168cdcec8fb9152e84a87ca6fd261b3d54c8f6358f42ab3b813b14a7193bb50b"}, -] - -[[package]] -name = "anyio" -version = "4.6.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.9" -files = [ - {file = "anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a"}, - {file = "anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} - -[package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, -] - -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "flake8" -version = "7.1.1" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213"}, - {file = "flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.12.0,<2.13.0" -pyflakes = ">=3.2.0,<3.3.0" - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jsonrpcserver" -version = "5.0.9" -description = "Process JSON-RPC requests" -optional = false -python-versions = "*" -files = [ - {file = "jsonrpcserver-5.0.9.tar.gz", hash = "sha256:a71fb2cfa18541c80935f60987f92755d94d74141248c7438847b96eee5c4482"}, -] - -[package.dependencies] -jsonschema = "<5" -oslash = "<1" - -[package.extras] -examples = ["aiohttp", "aiozmq", "flask", "flask-socketio", "gmqtt", "pyzmq", "tornado", "websockets", "werkzeug"] - -[[package]] -name = "jsonschema" -version = "4.23.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" -referencing = ">=0.28.4" -rpds-py = ">=0.7.1" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2023.12.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, - {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "latex2sympy2" -version = "1.8.3" -description = "Convert latex to sympy with ANTLR and support Matrix, Linear Algebra and CAS functions." -optional = false -python-versions = "*" -files = [] -develop = false - -[package.dependencies] -antlr4-python3-runtime = "4.7.2" -sympy = ">=1.4" - -[package.source] -type = "git" -url = "https://github.com/lambda-feedback/latex2sympy" -reference = "master" -resolved_reference = "402bb0a03202c9f5f627430b3293d89352626a9a" - -[[package]] -name = "lf-toolkit" -version = "0.0.1" -description = "" -optional = false -python-versions = "^3.9" -files = [] -develop = false - -[package.dependencies] -anyio = "4.6.0" -jsonrpcserver = "5.0.9" -pywin32 = {version = "^306", optional = true, markers = "sys_platform == \"win32\""} -sympy = "1.12" -ujson = "5.10.0" - -[package.extras] -http = ["fastapi (>=0.115.0,<0.116.0)"] -ipc = ["pywin32 (>=306,<307)"] -parsing = ["antlr4-python3-runtime (==4.13.2)", "lark (==1.2.2)", "latex2sympy @ git+https://github.com/purdue-tlt/latex2sympy.git@1.11.2"] - -[package.source] -type = "git" -url = "https://github.com/lambda-feedback/toolkit-python.git" -reference = "main" -resolved_reference = "b6a58d38a1f7ebd56ea36609b78005721b5c3549" - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mpmath" -version = "1.2.1" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -files = [ - {file = "mpmath-1.2.1-py3-none-any.whl", hash = "sha256:604bc21bd22d2322a177c73bdb573994ef76e62edd595d17e00aff24b0667e5c"}, - {file = "mpmath-1.2.1.tar.gz", hash = "sha256:79ffb45cf9f4b101a807595bcb3e72e0396202e0b1d25d689134b48c4216a81a"}, -] - -[package.extras] -develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] -tests = ["pytest (>=4.6)"] - -[[package]] -name = "oslash" -version = "0.6.3" -description = "OSlash (Ø) for Python 3.8+" -optional = false -python-versions = "*" -files = [ - {file = "OSlash-0.6.3-py3-none-any.whl", hash = "sha256:89b978443b7db3ac2666106bdc3680add3c886a6d8fcdd02fd062af86d29494f"}, - {file = "OSlash-0.6.3.tar.gz", hash = "sha256:868aeb58a656f2ed3b73d9dd6abe387b20b74fc9413d3e8653b615b15bf728f3"}, -] - -[package.dependencies] -typing-extensions = "*" - -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pycodestyle" -version = "2.12.1" -description = "Python style guide checker" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, - {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, -] - -[[package]] -name = "pydot" -version = "1.4.2" -description = "Python interface to Graphviz's Dot" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pydot-1.4.2-py2.py3-none-any.whl", hash = "sha256:66c98190c65b8d2e2382a441b4c0edfdb4f4c025ef9cb9874de478fb0793a451"}, - {file = "pydot-1.4.2.tar.gz", hash = "sha256:248081a39bcb56784deb018977e428605c1c758f10897a339fce1dd728ff007d"}, -] - -[package.dependencies] -pyparsing = ">=2.1.4" - -[[package]] -name = "pyflakes" -version = "3.2.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, -] - -[[package]] -name = "pyparsing" -version = "3.1.4" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.6.8" -files = [ - {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, - {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "8.3.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pywin32" -version = "306" -description = "Python for Window Extensions" -optional = false -python-versions = "*" -files = [ - {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, - {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, - {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, - {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, - {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, - {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, - {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, - {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, - {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, - {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, - {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, - {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, - {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, - {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, -] - -[[package]] -name = "referencing" -version = "0.35.1" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, - {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" - -[[package]] -name = "rpds-py" -version = "0.20.0" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, - {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, - {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, - {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, - {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, - {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, - {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, - {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, - {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, - {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, - {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, - {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, - {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, - {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, - {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, - {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, - {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, - {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, - {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, - {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, - {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, - {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, - {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, - {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, - {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, - {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, - {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, - {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, - {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, - {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, - {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, - {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, - {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, - {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, - {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, - {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, - {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, - {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sympy" -version = "1.12" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5"}, - {file = "sympy-1.12.tar.gz", hash = "sha256:ebf595c8dac3e0fdc4152c51878b498396ec7f30e7a914d6071e674d49420fb8"}, -] - -[package.dependencies] -mpmath = ">=0.19" - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "ujson" -version = "5.10.0" -description = "Ultra fast JSON encoder and decoder for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, - {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, - {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6"}, - {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569"}, - {file = "ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770"}, - {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1"}, - {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5"}, - {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51"}, - {file = "ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518"}, - {file = "ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f"}, - {file = "ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00"}, - {file = "ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126"}, - {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8"}, - {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b"}, - {file = "ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9"}, - {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f"}, - {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4"}, - {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1"}, - {file = "ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f"}, - {file = "ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720"}, - {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, - {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, - {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, - {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, - {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, - {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, - {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, - {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, - {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, - {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, - {file = "ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287"}, - {file = "ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e"}, - {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557"}, - {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988"}, - {file = "ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816"}, - {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20"}, - {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0"}, - {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f"}, - {file = "ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165"}, - {file = "ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539"}, - {file = "ujson-5.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a984a3131da7f07563057db1c3020b1350a3e27a8ec46ccbfbf21e5928a43050"}, - {file = "ujson-5.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73814cd1b9db6fc3270e9d8fe3b19f9f89e78ee9d71e8bd6c9a626aeaeaf16bd"}, - {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61e1591ed9376e5eddda202ec229eddc56c612b61ac6ad07f96b91460bb6c2fb"}, - {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c75269f8205b2690db4572a4a36fe47cd1338e4368bc73a7a0e48789e2e35a"}, - {file = "ujson-5.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7223f41e5bf1f919cd8d073e35b229295aa8e0f7b5de07ed1c8fddac63a6bc5d"}, - {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc2fd6b3067c0782e7002ac3b38cf48608ee6366ff176bbd02cf969c9c20fe"}, - {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:232cc85f8ee3c454c115455195a205074a56ff42608fd6b942aa4c378ac14dd7"}, - {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc6139531f13148055d691e442e4bc6601f6dba1e6d521b1585d4788ab0bfad4"}, - {file = "ujson-5.10.0-cp38-cp38-win32.whl", hash = "sha256:e7ce306a42b6b93ca47ac4a3b96683ca554f6d35dd8adc5acfcd55096c8dfcb8"}, - {file = "ujson-5.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:e82d4bb2138ab05e18f089a83b6564fee28048771eb63cdecf4b9b549de8a2cc"}, - {file = "ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b"}, - {file = "ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27"}, - {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76"}, - {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5"}, - {file = "ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0"}, - {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1"}, - {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1"}, - {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996"}, - {file = "ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9"}, - {file = "ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88"}, - {file = "ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7663960f08cd5a2bb152f5ee3992e1af7690a64c0e26d31ba7b3ff5b2ee66337"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8640fb4072d36b08e95a3a380ba65779d356b2fee8696afeb7794cf0902d0a1"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78778a3aa7aafb11e7ddca4e29f46bc5139131037ad628cc10936764282d6753"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0111b27f2d5c820e7f2dbad7d48e3338c824e7ac4d2a12da3dc6061cc39c8e6"}, - {file = "ujson-5.10.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c66962ca7565605b355a9ed478292da628b8f18c0f2793021ca4425abf8b01e5"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e"}, - {file = "ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7"}, - {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "3.10.15" -content-hash = "c8f1bd62341d997ff8823d200ad6ac1d050f807c37ec0a919e6fd71e761444fa" diff --git a/poetry.toml b/poetry.toml deleted file mode 100644 index ab1033b..0000000 --- a/poetry.toml +++ /dev/null @@ -1,2 +0,0 @@ -[virtualenvs] -in-project = true diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index bbe680b..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[tool.poetry] -name = "evaluation_function" -version = "0.0.1" -description = "" -authors = ["Andreas Pfurtscheller "] -readme = "README.md" -packages = [{ include = "evaluation_function" }] - -[tool.poetry.scripts] -evaluation_function = "evaluation_function.main:main" -evaluation_function_dev = "evaluation_function.dev:dev" - -[tool.poetry.dependencies] -python = "3.10.15" -typing_extensions = "^4.12.2" -lf_toolkit = { git = "https://github.com/lambda-feedback/toolkit-python.git", branch = "main", extras = [ - "ipc", -] } -pydot = "1.4.2" -mpmath = "1.2.1" -sympy = "1.12" -antlr4-python3-runtime = "4.7.2" -latex2sympy2 = { git = "https://github.com/lambda-feedback/latex2sympy", branch = "master" } -pytest = "^8.2.2" - -[tool.poetry.group.dev.dependencies] -pytest = "^8.2.2" -flake8 = "^7.1.0" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api"