From 1082321c271f367c0319ffc9c1c2b6c66f4e5c52 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 11 Sep 2026 20:34:58 +0000 Subject: [PATCH 01/26] Ignore extracted compareExpressions-packages nested repo The reusable packages extracted from this function live in /compareExpressions-packages/ as their own separate git repository. Ignore it so it is not accidentally added as an embedded repo/gitlink. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XP5RCStr93ME5UX4fzyj7y --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 14885ce..12b0788 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ app/benchmark.py app/profiling_examples.py .idea/ + +# Extracted packages live here as their own separate git repo +/compareExpressions-packages/ From 710a92aaf500fabfd6be4c99af7fd4fa3dad9b97 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 11:40:23 +0000 Subject: [PATCH 02/26] Depend on the extracted compareexpressions packages Add the four packages (slr_parsing, criteria, expression_parsing, units) as git requirements on the compareExpressions-packages repo at v0.2.0. All four are listed because they depend on each other by name and are not on PyPI. Nothing uses them yet; the following commits move the app over. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- app/requirements.txt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/requirements.txt b/app/requirements.txt index 1ee4286..dd477a7 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,4 +3,11 @@ typing_extensions mpmath==1.3.0 sympy==1.14 antlr4-python3-runtime==4.7.2 -git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2 \ No newline at end of file +git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2 +# Parsing, units and criteria packages extracted from this function +# (https://github.com/lambda-feedback/compareExpressions-packages). All four are +# listed: they depend on each other by name and aren't published on PyPI. +compareexpressions-slr-parsing @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=slr_parsing +compareexpressions-criteria @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=criteria +compareexpressions-expression-parsing @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=expression_parsing +compareexpressions-units @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=units From c88110ebd41b81a913082572ddf2e62ac25e08ff Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 11:44:16 +0000 Subject: [PATCH 03/26] Use compareexpressions.criteria and slr_parsing; add an app-local result Criteria graphs, the criteria parser and the physical-quantity criteria grammar now come from the compareexpressions-criteria and -slr-parsing packages. The retired EvaluationResult utility is replaced by app/result.py, which satisfies the criteria package's ResultLike protocol and serialises to the same response as before (blank feedback skipped, tags always present). Criteria feedback goes through criteria.add_feedback_from_tags and the test data through criteria.criteria_test_data. The criteria parser no longer appends the reserved expressions to a module-level token list on every evaluation. Across the app suite every response is unchanged except the criteria-graph test data: node summaries print `tags: {}` rather than `tags: {}`, and mermaid edges are listed in graph order (both in the packages' changelogs). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- app/Dockerfile | 4 +- app/context/physical_quantity.py | 11 +- app/context/symbolic.py | 6 +- app/evaluation.py | 28 +- app/result.py | 44 +++ app/utility/criteria_graph_utilities.py | 374 --------------------- app/utility/criteria_parsing.py | 93 ----- app/utility/evaluation_result_utilities.py | 71 ---- 8 files changed, 65 insertions(+), 566 deletions(-) create mode 100644 app/result.py delete mode 100644 app/utility/criteria_graph_utilities.py delete mode 100644 app/utility/criteria_parsing.py delete mode 100644 app/utility/evaluation_result_utilities.py diff --git a/app/Dockerfile b/app/Dockerfile index 22e2aee..f942420 100755 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -18,6 +18,7 @@ COPY evaluation.py ./app/ COPY evaluation_test.py ./app/ COPY preview.py ./app/ COPY preview_test.py ./app/ +COPY result.py ./app/ # Copy contexts COPY context/physical_quantity.py ./app/context/ @@ -40,9 +41,6 @@ COPY tests/symbolic_evaluation_test.py ./app/tests/ COPY tests/symbolic_preview_test.py ./app/tests/ # Copy utility code -COPY utility/criteria_graph_utilities.py ./app/utility/ -COPY utility/criteria_parsing.py ./app/utility/ -COPY utility/evaluation_result_utilities.py ./app/utility/ COPY utility/expression_utilities.py ./app/utility/ COPY utility/physical_quantity_utilities.py ./app/utility/ COPY utility/preview_utilities.py ./app/utility/ diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 66f8923..09745bd 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -26,17 +26,16 @@ 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 compareexpressions.criteria import CriteriaGraph +from compareexpressions.slr_parsing import SLRParser, catch_undefined, create_node, infix, operate 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) + for (tag, message) in quantity.messages: + evaluation_result.add_feedback(tag, message) if quantity.standard_value is not None: standard_value = quantity.standard_value else: @@ -111,7 +110,7 @@ def generate_criteria_parser(reserved_expressions): ("DIMENSION", "dimension(QUANTITY)", operate(1)), ] - return SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol) + return SLRParser(token_list, productions, start_symbol, end_symbol, null_symbol) def comparison_function(comparison_operator): diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 960c989..22e9535 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -15,8 +15,7 @@ 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 +from compareexpressions.criteria import CriteriaGraph, build_criteria_parser def expression_preprocess(name, expr, parameters): @@ -879,8 +878,7 @@ def feedback_string_generator(tags, graph, parameters_dict): context = { "expression_preview": preview_function, - "generate_criteria_parser": generate_criteria_parser, - "generate_feedback": generate_criteria_parser, + "generate_criteria_parser": build_criteria_parser, "expression_preprocess": expression_preprocess, "expression_parse": expression_parse, "default_parameters": default_parameters, diff --git a/app/evaluation.py b/app/evaluation.py index 4f86527..378edab 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,6 +1,8 @@ from copy import deepcopy -from .utility.evaluation_result_utilities import EvaluationResult +from compareexpressions.criteria import add_feedback_from_tags + +from .result 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 @@ -117,7 +119,7 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): preprocess_success, expr, preprocess_feedback = preprocess(key, expr, parameters) if preprocess_success is False: if key == "learner": - result.add_feedback(preprocess_feedback) + 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]) @@ -127,10 +129,8 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): if len(expr.strip()) == 0: if key == "learner": result.add_feedback( - ( - f"RESERVED_EXPRESSION_MISSING_{label}", - messages["RESERVED_EXPRESSION_MISSING"](label) - ) + f"RESERVED_EXPRESSION_MISSING_{label}", + messages["RESERVED_EXPRESSION_MISSING"](label) ) else: raise Exception(messages["RESERVED_EXPRESSION_MISSING"](label), f"RESERVED_EXPRESSION_MISSING_{label}") @@ -143,10 +143,8 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): success = False if key == "learner": result.add_feedback( - ( - f"PARSE_ERROR_{label}", - symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) - ) + f"PARSE_ERROR_{label}", + symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr}) ) else: raise Exception(symbolic_comparison_internal_messages("PARSE_ERROR")({'x': expr})) from e @@ -213,14 +211,14 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): # TODO: Implement way to define completeness of task other than "all main criteria satisfied" is_correct = is_correct and main_criteria in criteria_feedback - evaluation_result.add_criteria_graph(criterion_identifier, graph) + evaluation_result.criteria_graphs[criterion_identifier] = graph # Generate feedback strings from found feedback # NOTE: Feedback strings are generated for each graph due to the # assumption that some way to return partial feedback # before script has executed completely will be available # in the future - evaluation_result.add_feedback_from_tags(criteria_feedback, graph, custom_feedback=custom_feedback) + add_feedback_from_tags(evaluation_result, criteria_feedback, graph, custom_feedback=custom_feedback) evaluation_result.is_correct = is_correct return @@ -290,13 +288,13 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di # FIXME: Move this into expression_utilities if params.get("strict_syntax", False): if "^" in response: - evaluation_result.add_feedback(("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict()))) + evaluation_result.add_feedback("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict())) if "!" in response: - evaluation_result.add_feedback(("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict()))) + evaluation_result.add_feedback("NOTATION_WARNING_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_FACTORIAL")(dict())) if "!!!" in response: evaluation_result.add_feedback( - ("NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict()))) + "NOTATION_WARNING_TRIPLE_FACTORIAL", symbolic_comparison_internal_messages("NOTATION_WARNING_TRIPLE_FACTORIAL")(dict())) reserved_expressions_success, reserved_expressions = parse_reserved_expressions(reserved_expressions_strings, parameters, evaluation_result) if reserved_expressions_success is False: diff --git a/app/result.py b/app/result.py new file mode 100644 index 0000000..39d82aa --- /dev/null +++ b/app/result.py @@ -0,0 +1,44 @@ +from compareexpressions.criteria import criteria_test_data + + +class EvaluationResult: + """ + Collects the outcome of an evaluation and serialises it into the + response returned by `evaluation_function`. + + Satisfies `compareexpressions.criteria.ResultLike`, so the feedback + helpers in `compareexpressions.criteria.feedback` can add to it. + """ + def __init__(self): + self.is_correct = False + self.latex = "" + self.simplified = "" + self.criteria_graphs = {} # name -> CriteriaGraph, included in test data + self._feedback = [] # (tag, text) pairs in the order they were added + + @property + def tags(self): + return list(dict.fromkeys(tag for (tag, _) in self._feedback)) + + def get_feedback(self, tag): + return [text for (t, text) in self._feedback if t == tag] + + def add_feedback(self, tag, feedback): + """Record `feedback` under `tag`. Blank feedback records the tag without adding any text.""" + self._feedback.append((tag, feedback or "")) + + def _serialise_feedback(self): + return "
".join(text.strip() for (_, text) in self._feedback if len(text.strip()) > 0) + + def serialise(self, include_test_data=False) -> dict: + out = dict(is_correct=self.is_correct, feedback=self._serialise_feedback(), tags=self.tags) + if include_test_data is True: + out.update(criteria_test_data(self.criteria_graphs)) + if self.latex is not None: + out.update(dict(response_latex=self.latex)) + if self.simplified is not None: + simplified = self.simplified + if isinstance(simplified, list): + simplified = ", ".join(simplified) + out.update(dict(response_simplified=simplified)) + return out diff --git a/app/utility/criteria_graph_utilities.py b/app/utility/criteria_graph_utilities.py deleted file mode 100644 index b2d56d0..0000000 --- a/app/utility/criteria_graph_utilities.py +++ /dev/null @@ -1,374 +0,0 @@ -import json - -evaluation_style = ("([", "])") -starting_evaluation_style = (">", "]") -criterion_style = ("[", "]") -output_style = ("{{", "}}") -special_style = ("[[", "]]") - - -class CriteriaGraph: - - class Node: - def __init__(self, label, summary, details): - self.label = label - self.summary = summary - self.details = details - self.incoming = [] - self.outgoing = [] - return - - def __eq__(self, other): - return self.label == other.label and self.summary == other.summary and self.details == other.details - - class Evaluation(Node): - def __init__(self, label, summary, details, evaluate, replacement=None): - super().__init__(label, summary, details) - self.results = self.outgoing - self.evaluate = evaluate - self.replacement = replacement - return - - class Criterion(Node): - def __init__(self, label, summary, details, tags=None, feedback_string_generator=None): - super().__init__(label, summary, details) - self.consequences = self.outgoing - if feedback_string_generator is not None: - self.feedback_string_generator = feedback_string_generator - else: - self.feedback_string_generator = lambda x: None - if tags is None: - self.tags = set() - else: - self.tags = tags - return - - class Output(Node): - def __init__(self, label, summary, details, tags=None): - super().__init__(label, summary, details) - self.outgoing = [] - if tags is None: - self.tags = set() - else: - self.tags = tags - return - - class Edge: - def __init__(self, source, target): - self.source = source - self.target = target - return - - def __eq__(self, other): - return self.source.label == other.source.label and self.target.label == other.target.label - - def __hash__(self): - return (self.source.label, self.target.label).__hash__() - - RETURN = Output("RETURN", "RETURN", "Reached a previously visited node.") - END = Output("END", "END", "Evaluation completed.") - - class Tree(Node): - - def __init__(self, node, parent=None, outgoing=None, identifier=0, main_criteria=None): - super().__init__(node.label, node.summary, node.details) - self.identifier = "_"+str(identifier) - self.style = self.get_node_style(node) - self.type_label = self.get_node_type(node, main_criteria) - if parent is not None: - self.incoming = [parent] - if isinstance(node, CriteriaGraph.Output) or outgoing is None: - self.outgoing = [] - else: - self.outgoing = outgoing - return - - def get_node_type(self, node, main_criteria=None): - if main_criteria is None: - main_criteria = [] - if isinstance(node, CriteriaGraph.Evaluation): - type_label = "evaluation" - elif isinstance(node, CriteriaGraph.Criterion): - if node.label in main_criteria: - type_label = "main_criterion" - else: - type_label = "criterion" - elif isinstance(node, CriteriaGraph.Output): - type_label = "output" - else: - raise Exception("Cannot find style for this kind of node.") - return type_label - - def get_node_style(self, node): - if isinstance(node, CriteriaGraph.Evaluation): - style = evaluation_style - elif isinstance(node, CriteriaGraph.Criterion): - style = criterion_style - elif isinstance(node, CriteriaGraph.Output): - style = output_style - else: - raise Exception("Cannot find style for this kind of node.") - return style - - def mermaid(self, special_nodes=None): - if special_nodes is None: - special_nodes = [] - nodes = [self.label+self.identifier+('"'+self.summary+'"').join(starting_evaluation_style)] - edges = [self.label+self.identifier+" --> "+child.label+child.identifier for child in self.outgoing] - stack = self.outgoing - while len(stack) > 0: - child = stack.pop() - if child.label in special_nodes: - style = special_style - else: - style = child.style - nodes.append(child.label+child.identifier+('"'+child.summary+'"').join(style)) - edges += [child.label+child.identifier+" --> "+next_child.label+next_child.identifier for next_child in child.outgoing] - stack += child.outgoing - output = ["graph TD"]+nodes+edges - return "\n\t".join(output) - - def as_dictionary(self): - tree_dict = { - "label": self.label, - "summary": self.summary, - "details": self.details, - "type": self.type_label, - "children": [node.as_dictionary() for node in self.outgoing] - } - return tree_dict - - def json(self): - return str(json.dumps(self.as_dictionary())) - - def __init__(self, identifier, entry_evaluations=None): - self.identifier = identifier - self.evaluations = {} - self.criteria = {} - self.outputs = {} - self.sufficiencies = {} - self.entry_evaluations = entry_evaluations - return - - def json(self): - graph = { - "evaluations": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming], - "outgoing": [n.target.label for n in node.outgoing] - } for (label, node) in self.evaluations.items() - }, - "criteria": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming], - "outgoing": [n.target.label for n in node.outgoing] - } for (label, node) in self.criteria.items() - }, - "outputs": { - label: { - "summary": node.summary, - "details": node.details, - "incoming": [n.source.label for n in node.incoming] - } for (label, node) in self.outputs.items() - }, - "sufficiencies": {label: list(suffs) for (label, suffs) in self.sufficiencies.items() if suffs is not None}, - } - return str(json.dumps(graph)) - - def mermaid(self): - output = ["flowchart TD"] - linebreak = '
---
' - edges = set() - sufficiencies = set() - node_sets = [self.evaluations, self.criteria, self.outputs] - node_styles = [evaluation_style, criterion_style, output_style] - node_keys = {} - for set_index, nodes in enumerate(node_sets): - index = 0 - for (label, node) in nodes.items(): - node_keys.update({label: "N_"+str(set_index)+"_"+str(index)}) - index += 1 - for set_index, nodes in enumerate(node_sets): - style = node_styles[set_index] - for (label, node) in nodes.items(): - output.append(node_keys[label]+style[0]+'"'+label+linebreak+node.details+'"'+style[1]) - edges.update([(node_keys[edge.source.label], node_keys[edge.target.label]) for edge in node.outgoing+node.incoming]) - if self.sufficiencies.get(label, None) is not None: - sufficiencies.update([(label, sufficiency) for sufficiency in self.sufficiencies.get(label, None)]) - for edge in edges: - output.append(" --> ".join(edge)) - for sufficiency in sufficiencies: - output.append(" -.-> ".join(sufficiency)) - return "\n\t".join(output) - - def add_evaluation_node(self, label, summary, details, sufficiencies=None, evaluate=None, feedback_string_generator=None): - if feedback_string_generator is not None: - raise Exception(f"{label} is an evaluation node, evaluation nodes cannot generate feedback strings.") - if label in self.evaluations.keys(): - raise Exception(f"Evaluation node {label} is already defined.") - else: - node = CriteriaGraph.Evaluation(label, summary, details, evaluate) - self.evaluations.update({label: node}) - self.sufficiencies.update({label: sufficiencies}) - return node - - def add_criterion_node(self, label, summary, details, sufficiencies=None, evaluate=None, feedback_string_generator=None): - if label in self.criteria.keys(): - raise Exception(f"Criterion node {label} is already defined.") - if sufficiencies is not None: - 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}) - return node - - def add_output_node(self, label, summary, details): - if label in self.outputs.keys(): - raise Exception(f"Output node {label} is already defined.") - else: - node = CriteriaGraph.Output(label, summary, details) - self.outputs.update({label: node}) - return node - - def add_node(self, node): - if isinstance(node, CriteriaGraph.Evaluation): - self.add_evaluation_node(node.label, node.summary, node.details, node.sufficiencies) - elif isinstance(node, CriteriaGraph.Criterion): - self.add_criterion_node(node.label, node.summary, node.details) - 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.") - - def attach(self, source_label, target_label, summary=None, details=None, sufficiencies=None, evaluate=None, feedback_string_generator=None): - source = self.evaluations.get(source_label, None) - if source is None: - source = self.criteria.get(source_label, None) - if source is None: - raise Exception(f"Unknown node {source_label}.") - - if isinstance(source, CriteriaGraph.Evaluation): - target_set = self.criteria - target_generator = self.add_criterion_node - target_alternative_set = {} - target_wrong_type_set = self.evaluations - type_name = "evaluation" - other_type_name = "criterion" - elif isinstance(source, CriteriaGraph.Criterion): - target_set = self.evaluations - target_generator = self.add_evaluation_node - target_alternative_set = self.outputs - target_wrong_type_set = self.criteria - type_name = "criterion" - other_type_name = "evaluation" - elif isinstance(source, CriteriaGraph.Output): - raise Exception(f"{source_label} is an output nodes. Output nodes cannot have outgoing edges.") - else: - raise Exception(f"Source node {source_label} is an invalid type.") - - target = target_set.get(target_label, None) - if target is None: - target = target_alternative_set.get(target_label, None) - if target is None: - target = target_wrong_type_set.get(target_label, None) - if target is None: - if summary is None or details is None: - raise Exception(f"Unknown node {target_label}. If you wish to create a new node summary and details must be specified.") - else: - target = target_generator(target_label, summary=summary, details=details, sufficiencies=sufficiencies, evaluate=evaluate, feedback_string_generator=feedback_string_generator) - else: - raise Exception(f"Both {source_label} and {target_label} are {type_name} nodes. Only {other_type_name} nodes can be attached to {type_name} nodes.") - - edge = CriteriaGraph.Edge(source, target) - if edge in source.outgoing: - raise Exception(f"{target_label} is already attached to {source_label}.") - else: - source.outgoing.append(edge) - target.incoming.append(edge) - return - - def add_sufficiencies(self, source_label, sufficiencies): - if source_label in self.evaluations.keys(): - if self.sufficiencies.get(source_label, None) is None: - self.sufficiencies.update({source_label: []}) - for sufficiency in sufficiencies: - if sufficiency not in self.sufficiencies[source_label]: - self.sufficiencies[source_label].append(sufficiency) - else: - raise Exception(f"Unknown evaluation node {source_label}. Only evaluation nodes can have 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 - if label in self.criteria.keys(): - main_criteria = self.criteria[label] - base_starting_evaluations = set(edge.source.label for edge in main_criteria.incoming) - elif label in self.evaluations.keys(): - base_starting_evaluations = set([label]) - else: - raise Exception(f"No criterion or evaluation with label {label}.") - starting_evaluations = set() - candidate_starting_evaluations = base_starting_evaluations - while len(candidate_starting_evaluations) > 0: - label = candidate_starting_evaluations.pop() - if self.sufficiencies.get(label, None) is None: - starting_evaluations.update([label]) - else: - for sufficiency in self.sufficiencies.get(label, []): - candidate_starting_evaluations.update([edge.source.label for edge in self.criteria[sufficiency].incoming]) - if len(starting_evaluations) == 0: - starting_evaluations = base_starting_evaluations - return starting_evaluations - - def build_tree(self, starting_evaluation, return_node=RETURN, main_criteria=None): - node = self.evaluations.get(starting_evaluation, None) - if node is None: - raise Exception(f"Unknown evaluation node {node.label}.") - identifier = 0 - root_node = CriteriaGraph.Tree(node, identifier=identifier, main_criteria=main_criteria) - stack = [(edge.target, root_node) for (k, edge) in enumerate(node.outgoing)] - visited_nodes = [node.label] - while len(stack) > 0: - node, parent = stack.pop() - if node.label in visited_nodes: - parent.outgoing.append(CriteriaGraph.Tree(CriteriaGraph.Output("RETURN"+str(identifier), "Go to: "+node.label, "Reached a previously visited node."), parent=parent, identifier=identifier, main_criteria=main_criteria)) - else: - tree_node = CriteriaGraph.Tree(node, parent=parent, identifier=identifier, main_criteria=main_criteria) - parent.outgoing.append(tree_node) - if not isinstance(node, CriteriaGraph.Output): - visited_nodes.append(node.label) - stack += [(edge.target, tree_node) for (k, edge) in enumerate(node.outgoing)] - identifier += 1 - return root_node - - def trees(self, label): - trees = [self.build_tree(start, main_criteria=[label]) for start in self.starting_evaluations(label)] - return trees - - def generate_feedback(self, response, main_criteria): - evaluations = set().union(self.starting_evaluations(main_criteria)) - visited_evaluations = set() - feedback = dict() - while len(evaluations) > 0: - e = evaluations.pop() - if e in self.evaluations.keys() and self.evaluations[e].replacement is not None: - visited_evaluations.update({e}) - e = self.evaluations[e].replacement.label - if e not in visited_evaluations and e in self.evaluations.keys(): - visited_evaluations.update({e}) - try: - results = self.evaluations[e].evaluate(response) - except Exception as exc: - print(e) - print(self.evaluations) - raise exc - 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 diff --git a/app/utility/criteria_parsing.py b/app/utility/criteria_parsing.py deleted file mode 100644 index 2f06ea2..0000000 --- a/app/utility/criteria_parsing.py +++ /dev/null @@ -1,93 +0,0 @@ -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"), - (" *proportional +to *", "PROPORTIONAL_TO"), - (" *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), - ("EQUAL", "OTHER proportional to OTHER", infix), - ("EQUAL", "RESERVED proportional to OTHER", infix), - ("EQUAL", "OTHER proportional to RESERVED", infix), - ("EQUAL", "RESERVED proportional to 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/app/utility/evaluation_result_utilities.py b/app/utility/evaluation_result_utilities.py deleted file mode 100644 index f1c0eb5..0000000 --- a/app/utility/evaluation_result_utilities.py +++ /dev/null @@ -1,71 +0,0 @@ -class EvaluationResult: - def __init__(self): - self.is_correct = False - self.latex = None - self._feedback = [] # A list that will hold all feedback items - self._feedback_tags = {} # A dictionary that holds a list with indices to all feedback items with the same tag - self._criteria_graphs = {} - self._criteria_graphs_vis = {} - self.latex = "" - self.simplified = "" - - def get_feedback(self, tag): - return self._feedback_tags.get(tag, None) - - def get_tags(self): - return list(self._feedback_tags.keys()) - - def add_feedback(self, feedback_item): - if isinstance(feedback_item, tuple): - self._feedback.append(feedback_item[1]) - if feedback_item[0] not in self._feedback_tags.keys(): - self._feedback_tags.update({feedback_item[0]: [len(self._feedback)-1]}) - else: - self._feedback_tags[feedback_item[0]].append(len(self._feedback)-1) - else: - raise TypeError("Feedback must be on the form (tag, feedback).") - self._feedback_tags - - def add_feedback_from_tags(self, tags, graph, custom_feedback=None): - if custom_feedback is None: - custom_feedback = {} - for (tag, inputs) in tags.items(): - if tag not in self._feedback_tags.keys(): - if tag in custom_feedback.keys(): - feedback_string = custom_feedback[tag] - else: - 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()}) - self._criteria_graphs_vis.update({name: graph.mermaid()}) - - def _serialise_feedback(self) -> str: - feedback = [] - for x in self._feedback: - if (isinstance(x, tuple) and len(x[1].strip())) > 0: - feedback.append(x[1].strip()) - elif x is not None and len(x.strip()) > 0: - feedback.append(x.strip()) - return "
".join(feedback) - - def serialise(self, include_test_data=False) -> dict: - out = dict(is_correct=self.is_correct, feedback=self._serialise_feedback()) - out.update(dict(tags=list(self._feedback_tags.keys()))) - if include_test_data is True: - out.update(dict(criteria_graphs=self._criteria_graphs)) - out.update(dict(criteria_graphs_vis=self._criteria_graphs_vis)) - if self.latex is not None: - out.update(dict(response_latex=self.latex)) - if self.simplified is not None: - if isinstance(self.simplified, list): - self.simplified = ", ".join(self.simplified) - out.update(dict(response_simplified=self.simplified)) - return out - - def __getitem__(self, key): - return self.serialise(include_test_data=True)[key] From 60e58b5c04deb28f6a37142a1eee90c368ac4528 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 11:51:15 +0000 Subject: [PATCH 04/26] Use compareexpressions.expression_parsing for symbolic expressions Preprocessing, SymPy parsing, LaTeX parsing, the symbolic preview and the syntactical forms now come from compareexpressions-expression-parsing: - Parsing parameters are a SympyParsingConfig built from the evaluation parameters; variants use .replace() instead of deep-copied dicts. - Preprocessing returns a Preprocessed; its FeedbackTag is turned into text by feedback.symbolic.internal_feedback, with the same tags as before. - The JSON parameter defaults move into context/symbolic.py. - The physical-quantity context uses units.preprocess_quantity, so every context's preprocessing step has the same shape. - The tests pass plain dicts instead of the removed Params TypedDict. The package no longer mutates the caller's parameters. Before, a preview set `rationalise: False` and added a `lamda` symbol to the dict it was given, which leaked into later calls sharing that dict; the responses don't change as a result. Behaviour changes, from the package's bug fixes: - With strict syntax, arithmetic on `{}` set literals is rejected. Evaluating `{x+y}*{x+z}` used to crash with AttributeError; it now gives PARSE_ERROR feedback, and its preview raises instead of rendering set braces. test_curly_braces_not_converted_with_strict_syntax now expects the error. - The preview works for symbols defined without `latex` (it raised KeyError, so response_latex fell back to the raw response). - Preview failures raise ExpressionParsingError, a ValueError. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- app/context/physical_quantity.py | 7 ++-- app/context/symbolic.py | 71 +++++++++++++++++--------------- app/evaluation.py | 24 ++++++----- app/evaluation_test.py | 5 +-- app/feedback/symbolic.py | 5 +++ app/preview.py | 12 +++--- app/preview_test.py | 34 +++++++-------- 7 files changed, 86 insertions(+), 72 deletions(-) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 09745bd..4cec3bf 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -4,13 +4,14 @@ import re from copy import deepcopy +from compareexpressions.units import preprocess_quantity from ..utility.physical_quantity_utilities import ( SLR_quantity_parser, - SLR_quantity_parsing, expression_preprocess + 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 .symbolic import default_parameters as symbolic_default_parameters from ..utility.expression_utilities import ( substitute_input_symbols, create_sympy_parsing_params, @@ -515,7 +516,7 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as context = { "expression_preview": preview_function, "generate_criteria_parser": generate_criteria_parser, - "expression_preprocess": expression_preprocess, + "expression_preprocess": preprocess_quantity, "expression_parse": parse_quantity, "default_parameters": default_parameters, "default_criteria": default_criteria, diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 22e9535..0bf5b05 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,25 +1,32 @@ -from copy import deepcopy from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo from sympy import re as real_part -from ..utility.expression_utilities import ( - default_parameters, +from compareexpressions.expression_parsing import ( + PATTERNS as syntactical_forms, + ExpressionParams, + SympyParsingConfig, + generate_arbitrary_number_pattern_matcher, parse_expression, - create_sympy_parsing_params, preprocess_expression, + preview_function, ) -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 compareexpressions.criteria import CriteriaGraph, build_criteria_parser +default_parameters = { + "complexNumbers": False, + "convention": "equal_precedence", + "elementary_functions": False, + "strict_syntax": False, + "multiple_answers_criteria": "all", +} + + def expression_preprocess(name, expr, parameters): - return preprocess_expression(name, expr, parameters) + return preprocess_expression(name, expr, ExpressionParams.from_dict(parameters)) def expression_parse(name, expr, parameters, evaluation_result): @@ -31,8 +38,7 @@ def expression_parse(name, expr, parameters, evaluation_result): def check_criterion(criterion, parameters_dict, generate_feedback=True): label = criterion.label.strip() - parsing_params = deepcopy(parameters_dict["parsing_parameters"]) - parsing_params.update({"simplify": False}) + parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) if label in {"EQUALITY", "WRITTEN_AS"}: result = check_equality(criterion, parameters_dict) elif label == "ORDER": @@ -60,20 +66,17 @@ def check_criterion(criterion, parameters_dict, generate_feedback=True): def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): - parsing_params = deepcopy(parameters_dict["parsing_parameters"]) + parsing_params = parameters_dict["parsing_parameters"] reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params.update( - { - "simplify": False, - "evaluate": False, - "unsplittable_symbols": parsing_params["unsplittable_symbols"]+list((parameters_dict["reserved_expressions"].keys())), - } + parsing_params = parsing_params.replace( + simplify=False, + unsplittable_symbols=parsing_params.unsplittable_symbols+tuple(parameters_dict["reserved_expressions"].keys()), ) lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) rhs_expr = parse_expression(rhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) - if parsing_params.get("complexNumbers", False): + if parsing_params.complex_numbers: simplified_lhs_expr = lhs_expr.cancel().simplify() simplified_rhs_expr = rhs_expr.cancel().simplify() if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): @@ -344,7 +347,7 @@ def equality_equivalence(unused_input): symbols_in_equality_ratio = (res.args[0]-res.args[1]).simplify().free_symbols else: symbols_in_equality_ratio = ((res.args[0]-res.args[1])/(ans.args[0]-ans.args[1])).simplify().free_symbols - result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"]["constants"]) + result = {str(s) for s in symbols_in_equality_ratio}.issubset(parameters_dict["parsing_parameters"].constants) if result is True: return { label+"_TRUE": None @@ -361,13 +364,11 @@ def equality_equivalence(unused_input): rhs = criterion.children[1].content_string() def same_symbols(unused_input): - parsing_params = deepcopy(parameters_dict["parsing_parameters"]) + parsing_params = parameters_dict["parsing_parameters"] local_substitutions = list(parameters_dict["reserved_expressions"].items()) - parsing_params.update( - { - "simplify": False, - "unsplittable_symbols": parsing_params["unsplittable_symbols"]+list((parameters_dict["reserved_expressions"].keys())), - } + parsing_params = parsing_params.replace( + simplify=False, + unsplittable_symbols=parsing_params.unsplittable_symbols+tuple(parameters_dict["reserved_expressions"].keys()), ) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) @@ -547,7 +548,7 @@ def written_as_known_form(string, name): def find_form(unused_input): forms = set() for form_label in syntactical_forms.keys(): - if syntactical_forms[form_label]["matcher"](string) is True: + if syntactical_forms[form_label].matcher(string) is True: forms.add(form_label) if len(forms) > 0: return {name+"_WRITTEN_AS_"+form_label: None for form_label in forms} @@ -558,7 +559,7 @@ def find_form(unused_input): def compare_to_form(string, form_label): def inner(unused_input): result = dict() - if syntactical_forms[form_label]["matcher"](string) is True: + if syntactical_forms[form_label].matcher(string) is True: result.update({label+"_TRUE": None}) else: result.update({label+"_FALSE": None}) @@ -625,15 +626,15 @@ def response_written_as_answer(unused_input): 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.", + 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.", + 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} ) @@ -876,6 +877,10 @@ def feedback_string_generator(tags, graph, parameters_dict): return +def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): + return SympyParsingConfig.from_params(ExpressionParams.from_dict(params), unsplittable_symbols, symbol_assumptions) + + context = { "expression_preview": preview_function, "generate_criteria_parser": build_criteria_parser, @@ -885,5 +890,5 @@ def feedback_string_generator(tags, graph, parameters_dict): "default_criteria": default_criteria, "feedback_procedure_generator": feedback_procedure_generator, "feedback_string_generator": feedback_string_generator, - "parsing_parameters_generator": create_sympy_parsing_params, + "parsing_parameters_generator": parsing_parameters_generator, } diff --git a/app/evaluation.py b/app/evaluation.py index 378edab..7c2f32a 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,12 +1,13 @@ from copy import deepcopy from compareexpressions.criteria import add_feedback_from_tags +from compareexpressions.expression_parsing import ExpressionParams, parse_latex from .result 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 .feedback.symbolic import internal_feedback from collections.abc import Mapping @@ -116,13 +117,15 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): reserved_expressions_dict.update({key: FrozenValuesDictionary()}) for (label, expr) in reserved_expressions[key].items(): expr_parsed = None - preprocess_success, expr, preprocess_feedback = preprocess(key, expr, parameters) - if preprocess_success is False: + preprocessed = preprocess(key, expr, parameters) + expr = preprocessed.expression + if preprocessed.success is False: + feedback_tag = str(preprocessed.feedback.tag) if key == "learner": - result.add_feedback(*preprocess_feedback) - preprocess_success = True # Preprocess can only create warnings for responses in this case + # Preprocess can only create warnings for responses in this case + result.add_feedback(feedback_tag, internal_feedback(preprocessed.feedback)) else: - raise Exception(preprocess_feedback[1], preprocess_feedback[0]) + raise Exception(internal_feedback(preprocessed.feedback), feedback_tag) reserved_expressions[key][label] = expr if not isinstance(expr, str): raise Exception(f"Reserved expression {label} must be given as a string.") @@ -164,9 +167,10 @@ def get_criteria_string(parameters): def create_criteria_dict(criteria_parser, parsing_params): preprocess = parsing_params["context"]["expression_preprocess"] criteria_string = get_criteria_string(parsing_params) - preprocess_success, criteria_string, preprocess_feedback = preprocess("criteria", criteria_string, parsing_params) - if preprocess_success is False: - raise Exception(preprocess_feedback[1], preprocess_feedback[0]) + preprocessed = preprocess("criteria", criteria_string, parsing_params) + criteria_string = preprocessed.expression + if preprocessed.success is False: + raise Exception(internal_feedback(preprocessed.feedback), str(preprocessed.feedback.tag)) criteria_string_list = [] delims = [ ("(", ")"), @@ -281,7 +285,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di if parameters.get("is_latex", False): parameters["reserved_expressions_strings"]["learner"].update( { - "response": parse_latex(response, parameters.get("symbols", {}), False, parameters=parameters), + "response": parse_latex(response, ExpressionParams.from_dict(parameters).symbols), } ) diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 6e97e9b..9bc837d 100755 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -2,7 +2,6 @@ import os from .preview import preview_function -from .utility.preview_utilities import Params from .evaluation import evaluation_function @@ -96,7 +95,7 @@ def test_physical_qualities_no_tolerance(self): def test_euler_preview_evaluate(self): response = "ER_2" - params = Params(is_latex=True, elementary_functions=False, strict_syntax=False) + params = dict(is_latex=True, elementary_functions=False, strict_syntax=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -214,7 +213,7 @@ def test_strict_syntax_rejects_implicit_multiplication(self, params, is_correct) def test_mu_preview_evaluate(self): response = "10 μA" - params = Params(is_latex=False, elementary_functions=False, strict_syntax=False, physical_quantity=True) + params = dict(is_latex=False, elementary_functions=False, strict_syntax=False, physical_quantity=True) result = preview_function(response, params) assert "preview" in result.keys() diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index 366ac07..d6c1ee8 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -77,3 +77,8 @@ "FALSE": None, "UNKNOWN": None, }[tag] + + +def internal_feedback(feedback_tag): + """Text for a `FeedbackTag` raised by the compareexpressions packages (e.g. `BRACKET_NOTATION_MISMATCH`).""" + return feedback_generators["INTERNAL"](str(feedback_tag.tag))(feedback_tag.inputs) diff --git a/app/preview.py b/app/preview.py index cebefd6..1f8c82c 100644 --- a/app/preview.py +++ b/app/preview.py @@ -1,13 +1,13 @@ -from .utility.preview_utilities import ( - Params, - Result, -) +from collections.abc import Mapping +from typing import Any + +from compareexpressions.expression_parsing import Result +from compareexpressions.expression_parsing import preview_function as symbolic_preview 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: +def preview_function(response: str, params: Mapping[str, Any]) -> Result: """ Function used to preview a student response. --- diff --git a/app/preview_test.py b/app/preview_test.py index eca9121..8f7f952 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -1,7 +1,6 @@ import os import pytest -from .utility.preview_utilities import Params from .preview import preview_function @@ -32,7 +31,7 @@ class TestPreviewFunction(): def test_empty_latex_expression(self): response = "" - params = Params(is_latex=True) + params = dict(is_latex=True) result = preview_function(response, params) assert "preview" in result.keys() @@ -41,7 +40,7 @@ def test_empty_latex_expression(self): def test_empty_non_latex_expression(self): response = "" - params = Params(is_latex=False) + params = dict(is_latex=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -50,7 +49,7 @@ def test_empty_non_latex_expression(self): def test_latex_and_sympy_are_returned(self): response = "x+1" - params = Params(is_latex=True) + params = dict(is_latex=True) result = preview_function(response, params) assert "preview" in result.keys() @@ -59,7 +58,7 @@ def test_latex_and_sympy_are_returned(self): assert "sympy" in preview.keys() response = "x+1" - params = Params(is_latex=False) + params = dict(is_latex=False) result = preview_function(response, params) assert "preview" in result @@ -69,7 +68,7 @@ def test_latex_and_sympy_are_returned(self): def test_natural_logarithm_notation(self): response = "ln(x)" - params = Params(is_latex=False) + params = dict(is_latex=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -108,7 +107,7 @@ def test_natural_logarithm_notation(self): ] ) def test_eulers_number_notation(self, response, is_latex, elementary_functions, response_latex, response_sympy): - params = Params(is_latex=is_latex, elementary_functions=elementary_functions, strict_syntax=False) + params = dict(is_latex=is_latex, elementary_functions=elementary_functions, strict_syntax=False) result = preview_function(response, params) assert "preview" in result.keys() @@ -199,7 +198,7 @@ def test_using_plus_minus_symbols(self, response, is_latex, response_latex, resp }, }, } - params = Params(**params) + params = dict(**params) result = preview_function(response, params) assert result["preview"]["latex"] == response_latex assert result["preview"]["sympy"] == response_sympy @@ -220,7 +219,7 @@ def test_lh_rh_response(self): }, }, } - params = Params(**params) + params = dict(**params) result = preview_function("x + y = y + x", params) assert result["preview"]["latex"] == "x + y=x + y" assert result["preview"]["sympy"] == "x + y=y + x" @@ -336,13 +335,15 @@ def test_brackets_rejected_with_strict_syntax(self, response): preview_function(response, params) @pytest.mark.parametrize( - "response, latex, sympy", [ - ("{x+y}*{x+z}", "\\left\\{x + y\\right\\} \\cdot \\left\\{x + z\\right\\}", "{x+y}*{x+z}"), - ("{x+y}*x+z", "\\left\\{x + y\\right\\} \\cdot x + z", "{x+y}*x+z"), - ("x+y*{x+z}", "x + y \\cdot \\left\\{x + z\\right\\}", "x+y*{x+z}"), + "response", [ + "{x+y}*{x+z}", + "{x+y}*x+z", + "x+y*{x+z}", ] ) - def test_curly_braces_not_converted_with_strict_syntax(self, response, latex, sympy): + def test_curly_braces_not_converted_with_strict_syntax(self, response): + # With strict syntax `{}` is a set literal, not a bracket, and arithmetic on + # sets is rejected (SymPy deprecates it, and evaluating it used to crash). params = { "is_latex": False, "strict_syntax": True, @@ -350,9 +351,8 @@ def test_curly_braces_not_converted_with_strict_syntax(self, response, latex, sy "convention": "implicit_higher_precedence", } - result = preview_function(response, params) - assert result["preview"]["latex"] == latex - assert result["preview"]["sympy"] == sympy + with pytest.raises(ValueError): + preview_function(response, params) @pytest.mark.parametrize("strict_syntax", [False, True]) def test_curly_brace_multiple_answers_still_works(self, strict_syntax): From 93909cd58775fc73005758c8eabc6778a36fff34 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 11:57:21 +0000 Subject: [PATCH 05/26] Use compareexpressions.units for physical quantities Quantity parsing, the unit data and the quantity preview now come from compareexpressions-units, and app/utility and app/preview_implementations are deleted: nothing uses the copies any more. - The physical-quantity context's parsing parameters hold a SympyParsingConfig, the QuantityParams used to read the response and answer, and the tolerances, which used to ride in the parsing dict. QuantityParams is built from the same subset of parameters the old parser saw, so `symbols`, `units_string` and `is_latex` still don't apply when reading quantities during evaluation (unchanged behaviour). - REVERTED_UNIT FeedbackTags are turned into text by feedback.physical_quantity.internal_feedback. - The unit tests of the removed modules were ported to the packages (expression_utilities, multi_character, syntactical_comparison, symbolic_preview, slr_quantity, physical_quantity_preview) and are deleted here. slr_quantity_test's case lists, which the evaluation tests share, move to tests/physical_quantity_test_cases.py. - typing_extensions is no longer needed. Behaviour change, from the slr_parsing fix to GROUP traversal: a reverted unit inside a group is reported once instead of twice, so responses such as `(kilogrammegametre^2)/(fs^4daA)` get one fewer duplicate REVERTED_UNIT message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- app/Dockerfile | 16 +- app/context/physical_quantity.py | 90 +- app/feedback/physical_quantity.py | 5 + app/preview.py | 3 +- app/preview_implementations/__init__.py | 0 .../physical_quantity_preview.py | 128 --- .../symbolic_preview.py | 134 --- app/preview_test.py | 9 +- app/requirements.txt | 1 - app/tests/expression_utilities_test.py | 483 ---------- app/tests/multi_character_test.py | 223 ----- .../physical_quantity_evaluation_test.py | 26 +- app/tests/physical_quantity_preview_test.py | 111 --- ...est.py => physical_quantity_test_cases.py} | 137 +-- app/tests/symbolic_evaluation_test.py | 3 +- app/tests/symbolic_preview_test.py | 258 ------ .../syntactical_comparison_utilities_test.py | 25 - app/utility/__init__.py | 0 app/utility/expression_utilities.py | 868 ------------------ app/utility/physical_quantity_utilities.py | 602 ------------ app/utility/preview_utilities.py | 245 ----- app/utility/slr_parsing_utilities.py | 845 ----------------- .../syntactical_comparison_utilities.py | 100 -- app/utility/unit_system_conversions.py | 145 --- 24 files changed, 69 insertions(+), 4388 deletions(-) delete mode 100644 app/preview_implementations/__init__.py delete mode 100644 app/preview_implementations/physical_quantity_preview.py delete mode 100644 app/preview_implementations/symbolic_preview.py delete mode 100644 app/tests/expression_utilities_test.py delete mode 100644 app/tests/multi_character_test.py delete mode 100644 app/tests/physical_quantity_preview_test.py rename app/tests/{slr_quantity_test.py => physical_quantity_test_cases.py} (70%) delete mode 100644 app/tests/symbolic_preview_test.py delete mode 100644 app/tests/syntactical_comparison_utilities_test.py delete mode 100644 app/utility/__init__.py delete mode 100644 app/utility/expression_utilities.py delete mode 100644 app/utility/physical_quantity_utilities.py delete mode 100644 app/utility/preview_utilities.py delete mode 100644 app/utility/slr_parsing_utilities.py delete mode 100644 app/utility/syntactical_comparison_utilities.py delete mode 100644 app/utility/unit_system_conversions.py diff --git a/app/Dockerfile b/app/Dockerfile index f942420..823dfdd 100755 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -28,25 +28,11 @@ COPY context/symbolic.py ./app/context/ COPY feedback/physical_quantity.py ./app/feedback/ COPY feedback/symbolic.py ./app/feedback/ -# Copy preview implementations -COPY preview_implementations/physical_quantity_preview.py ./app/preview_implementations/ -COPY preview_implementations/symbolic_preview.py ./app/preview_implementations/ - # Copy tests COPY tests/example_test.py ./app/tests/ COPY tests/physical_quantity_evaluation_test.py ./app/tests/ -COPY tests/physical_quantity_preview_test.py ./app/tests/ -COPY tests/slr_quantity_test.py ./app/tests/ +COPY tests/physical_quantity_test_cases.py ./app/tests/ COPY tests/symbolic_evaluation_test.py ./app/tests/ -COPY tests/symbolic_preview_test.py ./app/tests/ - -# Copy utility code -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 diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 4cec3bf..50075b5 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -2,47 +2,33 @@ # IMPORTS # ------- -import re from copy import deepcopy -from compareexpressions.units import preprocess_quantity -from ..utility.physical_quantity_utilities import ( - SLR_quantity_parser, - SLR_quantity_parsing, +from compareexpressions.criteria import CriteriaGraph +from compareexpressions.expression_parsing import ( + ExpressionParams, + SympyParsingConfig, + compute_relative_tolerance_from_significant_decimals, + parse_expression, ) -from ..preview_implementations.physical_quantity_preview import preview_function +from compareexpressions.units import QuantityParams, preprocess_quantity, preview_function +from compareexpressions.units import parse_quantity as parse_physical_quantity from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators +from ..feedback.physical_quantity import internal_feedback from .symbolic 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 compareexpressions.criteria import CriteriaGraph from compareexpressions.slr_parsing import SLRParser, catch_undefined, create_node, infix, operate from sympy import Symbol def parse_quantity(name, expr, parameters, evaluation_result): - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(expr, parameters, parser, name) + quantity = parse_physical_quantity(expr, parameters["quantity"], name) for (tag, message) in quantity.messages: - evaluation_result.add_feedback(tag, message) + evaluation_result.add_feedback(tag, internal_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) + value = parse_expression(quantity.value.content_string(), quantity.parsing_config) else: value = None if quantity.standard_unit is not None: @@ -50,7 +36,7 @@ def parse_quantity(name, expr, parameters, evaluation_result): else: standard_unit = None if quantity.unit is not None: - unit = parse_expression(quantity.unit.content_string(), quantity.parsing_params) + unit = parse_expression(quantity.unit.content_string(), quantity.parsing_config) else: unit = None quantity_dict = { @@ -139,13 +125,7 @@ def comparison_base_graph(criterion, parameters, comparison_operator="=", 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}) + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) if label is None: label = criterion.content_string() @@ -217,13 +197,13 @@ def criterion_match_node(criterion, parameters, label=None): END = CriteriaGraph.END graph.add_node(END) reserved_expressions = parameters["reserved_expressions"].items() - parsing_params = deepcopy(parameters["parsing_parameters"]) + parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) + rtol = parameters["parsing_parameters"]["rtol"] + atol = parameters["parsing_parameters"]["atol"] if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0: ans = parameters["reserved_expressions"]["answer"]["quantity"].value if ans is not None: rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string()) - parsing_params.update({'rtol': rtol}) - parsing_params.update({"simplify": False, "evaluate": False}) if label is None: label = criterion.content_string() @@ -277,16 +257,16 @@ def quantity_match(unused_inputs): if value_match is False: # TODO: better analysis of where `answer` is found in the criteria so that # numerical tolerances can be applied appropriately - if parsing_params.get('rtol', 0) > 0 or parsing_params.get('atol', 0) > 0: + if rtol > 0 or atol > 0: if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'): ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify() res = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify() if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()): - if parsing_params.get('rtol', 0) > 0 and (ans != 0): - value_match = bool(abs(float((ans-res)/ans)) < parsing_params['rtol']) - elif parsing_params.get('atol', 0) > 0 or (ans == 0): - answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].converted_unit_factor) - value_match = bool(abs(float(ans-res)) < parsing_params['atol']*answer_unit_factor) + if rtol > 0 and (ans != 0): + value_match = bool(abs(float((ans-res)/ans)) < rtol) + elif atol > 0 or (ans == 0): + answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].unit_factor) + value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor) substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions] unit_match = is_equal(lhs, rhs, substitutions) @@ -488,14 +468,28 @@ def feedback_string_generator(tags, graph, parameters_dict): return +# The evaluation parameters that affect how quantities are read. Other parameters +# (e.g. `symbols`, `units_string` and `is_latex`) have never applied when reading +# the response and answer as quantities during evaluation, only in the preview. +QUANTITY_PARSING_PARAMETERS = ( + "complexNumbers", + "convention", + "elementary_functions", + "rationalise", + "reserved_keywords", + "simplify", + "strict_syntax", + "strictness", +) + + def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): - parsing_parameters = create_sympy_parsing_params(params) - parsing_parameters.update({ - "strictness": params.get("strictness", "natural"), + return { + "sympy": SympyParsingConfig.from_params(ExpressionParams.from_dict(params)), + "quantity": QuantityParams.from_dict({key: params[key] for key in QUANTITY_PARSING_PARAMETERS if key in params}), "rtol": float(params.get("rtol", 0)), "atol": float(params.get("atol", 0)), - }) - return parsing_parameters + } # CONSIDER: Move these to separate file so that they can be shared with diff --git a/app/feedback/physical_quantity.py b/app/feedback/physical_quantity.py index f4320f7..989ae5d 100644 --- a/app/feedback/physical_quantity.py +++ b/app/feedback/physical_quantity.py @@ -20,3 +20,8 @@ "TRUE": "", # TODO: Replace with more specialised messages for different comparisons "FALSE": "", # TODO: Replace with more specialised messages for different comparisons }[tag] + + +def internal_feedback(feedback_tag): + """Text for a `FeedbackTag` raised by the compareexpressions packages (e.g. `REVERTED_UNIT`).""" + return feedback_string_generators["INTERNAL"](str(feedback_tag.tag))(feedback_tag.inputs) diff --git a/app/preview.py b/app/preview.py index 1f8c82c..134f940 100644 --- a/app/preview.py +++ b/app/preview.py @@ -3,8 +3,7 @@ from compareexpressions.expression_parsing import Result from compareexpressions.expression_parsing import preview_function as symbolic_preview - -from .preview_implementations.physical_quantity_preview import preview_function as physical_quantity_preview +from compareexpressions.units import preview_function as physical_quantity_preview def preview_function(response: str, params: Mapping[str, Any]) -> Result: diff --git a/app/preview_implementations/__init__.py b/app/preview_implementations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/preview_implementations/physical_quantity_preview.py b/app/preview_implementations/physical_quantity_preview.py deleted file mode 100644 index 7653f1a..0000000 --- a/app/preview_implementations/physical_quantity_preview.py +++ /dev/null @@ -1,128 +0,0 @@ -import tokenize -from copy import deepcopy - -from ..utility.expression_utilities import ( - find_matching_parenthesis, - parse_expression, - SymbolDict, - sympy_to_latex, -) - -from ..utility.preview_utilities import ( - Params, - Preview, - Result, - parse_latex, - sanitise_latex, -) - -from ..utility.expression_utilities import default_parameters as symbolic_default_parameters -from ..utility.physical_quantity_utilities import SLR_quantity_parser as quantity_parser, expression_preprocess -from ..utility.physical_quantity_utilities import SLR_quantity_parsing as quantity_parsing - -# CONSIDER: Move these to separate file so that they can be shared with -# the physical quantity context (or move preview implementation into context file) -default_parameters = deepcopy(symbolic_default_parameters) -default_parameters.update( - { - "physical_quantity": True, - "strictness": "natural", - "units_string": "SI common imperial", - } -) - - -def fix_exponents(response): - processed_response = [] - exponents_notation = ['^', '**'] - for notation in exponents_notation: - index = 0 - while index < len(response): - exponent_start = response.find(notation, index) - if exponent_start > -1: - processed_response.append(response[index:exponent_start]) - exponent_start += len(notation) - processed_response.append("**") - exponent_end = find_matching_parenthesis(response, exponent_start, delimiters=('{', '}')) - if exponent_end > 0: - inside_exponent = '('+response[(exponent_start+len(notation)):exponent_end]+')' - processed_response.append(inside_exponent) - index = exponent_end+1 - else: - index = exponent_start - else: - processed_response.append(response[index:]) - break - response = "".join(processed_response) - processed_response = [] - return response - - -def preview_function(response: str, params: Params) -> Result: - """ - Function used to preview a student response. - --- - The handler function passes three arguments to preview_function(): - - - `response` which are the answers provided by the student. - - `params` which are any extra parameters that may be useful, - e.g., error tolerances. - - The output of this function is what is returned as the API response - and therefore must be JSON-encodable. It must also conform to the - response schema. - - Any standard python library may be used, as well as any package - available on pip (provided it is added to requirements.txt). - - The way you wish to structure you code (all in this function, or - split into many) is entirely up to you. - """ - for (key, value) in default_parameters.items(): - if key not in params.keys(): - params.update({key: value}) - symbols: SymbolDict = params.get("symbols", {}) - - if not response: - return Result(preview=Preview(latex="", sympy="")) - - latex_out = "" - sympy_out = "" - - parser = quantity_parser(params) - - try: - if params.get("is_latex", False): - response = sanitise_latex(response) - response = fix_exponents(response) - res_parsed = quantity_parsing(response, params, parser, "response") - value = res_parsed.value - unit = res_parsed.unit - value_latex = "" - if value is not None: - value_string = parse_latex(value.content_string(), symbols, params.get("simplify", False)) - params.update({"is_latex": False}) - value = parse_expression(value_string, params) - value_latex = sympy_to_latex(value, symbols) - separator_latex = "" - separator_sympy = "" - if value is not None and unit is not None: - separator_latex = "~" - separator_sympy = " " - unit_latex = res_parsed.unit_latex_string if unit is not None else "" - latex_out = value_latex+separator_latex+unit_latex - value_sympy = str(value) - unit_sympy = res_parsed.unit.content_string() if unit is not None else "" - sympy_out = value_sympy+separator_sympy+unit_sympy - else: - _, res_pre_processed, _ = expression_preprocess("response", response, params) - res_parsed = quantity_parsing(res_pre_processed, params, parser, "response") - latex_out = res_parsed.latex_string - sympy_out = response - - except (SyntaxError, tokenize.TokenError) as e: - raise Exception("Failed to parse Sympy expression") from e - except ValueError as e: - raise Exception("Failed to parse LaTeX expression") from e - - return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) diff --git a/app/preview_implementations/symbolic_preview.py b/app/preview_implementations/symbolic_preview.py deleted file mode 100644 index 79e286b..0000000 --- a/app/preview_implementations/symbolic_preview.py +++ /dev/null @@ -1,134 +0,0 @@ -from sympy.parsing.sympy_parser import T as parser_transformations -from ..utility.expression_utilities import ( - default_parameters, - convert_absolute_notation, - create_expression_set, - create_sympy_parsing_params, - parse_expression, - substitute_input_symbols, - SymbolDict, - sympy_symbols, - sympy_to_latex, - preprocess_expression, -) -from ..utility.preview_utilities import ( - Params, - Preview, - Result, - parse_latex -) -from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators - - -def parse_symbolic(response: str, params): - symbolic_comparison_internal_messages = symbolic_feedback_string_generators["INTERNAL"] - response_list_in = create_expression_set(response, params) - response_list_out = [] - feedback = [] - for response in response_list_in: - response = substitute_input_symbols([response.strip()], params)[0] - - # Converting absolute value notation to a form that SymPy accepts - response, response_feedback = convert_absolute_notation(response, "response") - if response_feedback is not None: - feedback.append(response_feedback) - response_list_out.append(response) - - parsing_params = create_sympy_parsing_params(params) - parsing_params["extra_transformations"] = parser_transformations[9] # Add conversion of equal signs - parsing_params["symbol_dict"].update(sympy_symbols(params.get("symbols", {}))) - result_sympy_expression = [] - for response in response_list_out: - # Safely try to parse answer and response into symbolic expressions - try: - if "atol" in params.keys(): - parsing_params.update({"atol": params["atol"]}) - if "rtol" in params.keys(): - parsing_params.update({"rtol": params["rtol"]}) - res = parse_expression(response, parsing_params) - except Exception as exc: - raise SyntaxError(symbolic_comparison_internal_messages("PARSE_ERROR")({"response": response})) from exc - result_sympy_expression.append(res) - - return result_sympy_expression, feedback - - -def preview_function(response: str, params: Params) -> Result: - """ - Function used to preview a student response. - --- - The handler function passes three arguments to preview_function(): - - - `response` which are the answers provided by the student. - - `params` which are any extra parameters that may be useful, - e.g., error tolerances. - - The output of this function is what is returned as the API response - and therefore must be JSON-encodable. It must also conform to the - response schema. - - Any standard python library may be used, as well as any package - available on pip (provided it is added to requirements.txt). - - The way you wish to structure you code (all in this function, or - split into many) is entirely up to you. - """ - for (key, value) in default_parameters.items(): - if key not in params.keys(): - params.update({key: value}) - - original_response = response - - symbols: SymbolDict = params.get("symbols", {}) - - if not response: - return Result(preview=Preview(latex="", sympy="")) - - response_list = response.split("=") - response_latex = [] - response_sympy = [] - - for response in response_list: - try: - if params.get("is_latex", False) is True: - sympy_out = [parse_latex(response, symbols, params.get("simplify", False))] - latex_out = [response] - else: - params.update({"rationalise": False}) - _, response, _ = preprocess_expression("response", response, params) - expression_list, _ = parse_symbolic(response, params) - - parsing_params = create_sympy_parsing_params(params) - printing_symbols = dict() - for key in parsing_params["symbol_dict"].keys(): - if key in symbols.keys(): - printing_symbols.update({key: symbols[key]["latex"]}) - - latex_out = [] - sympy_out = [] - for expression in expression_list: - latex_out.append(sympy_to_latex(expression, symbols, settings={"mul_symbol": r" \cdot "})) - - sympy_out.append(response) - - sympy_out = sorted(sympy_out) - latex_out = sorted(latex_out) - - if len(sympy_out) == 1: - sympy_out = sympy_out[0] - sympy_out = str(sympy_out) - - if len(latex_out) > 1: - latex_out = "\\left\\{"+",~".join(latex_out)+"\\right\\}" - else: - latex_out = latex_out[0] - - except SyntaxError as exc: - raise ValueError(f"Failed to parse SymPy expression: {original_response}") from exc - except ValueError as exc: - raise ValueError(f"Failed to parse LaTeX expression: {original_response}") from exc - - response_latex.append(latex_out) - response_sympy.append(sympy_out) - - return Result(preview=Preview(latex="=".join(response_latex), sympy="=".join(response_sympy))) diff --git a/app/preview_test.py b/app/preview_test.py index 8f7f952..56e50ef 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -21,13 +21,10 @@ class TestPreviewFunction(): Use preview_function() to check your algorithm works as it should. - """ - - # Import tests that makes sure that mathematical expression comparison works as expected - from .tests.symbolic_preview_test import TestPreviewFunction as TestSymbolicComparison - # Import tests that makes sure that physical quantities are handled as expected - from .tests.physical_quantity_preview_test import TestPreviewFunction as TestQuantityComparison + The symbolic and physical quantity previews themselves are tested in the + compareexpressions-expression-parsing and compareexpressions-units packages. + """ def test_empty_latex_expression(self): response = "" diff --git a/app/requirements.txt b/app/requirements.txt index dd477a7..a0b463e 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,5 +1,4 @@ pydot -typing_extensions mpmath==1.3.0 sympy==1.14 antlr4-python3-runtime==4.7.2 diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py deleted file mode 100644 index c743291..0000000 --- a/app/tests/expression_utilities_test.py +++ /dev/null @@ -1,483 +0,0 @@ -import pytest -from sympy import Symbol, sqrt, sin as sympy_sin - -from ..utility.expression_utilities import ( - compute_relative_tolerance_from_significant_decimals, - convert_absolute_notation, - convert_bracket_notation, - convert_unicode_dashes, - create_expression_set, - extract_latex, - find_matching_parenthesis, - has_matching_brackets, - is_multiple_answers_wrapper, - latex_symbols, - preprocess_expression, - protect_elementary_functions_substitutions, - substitute, - substitute_input_symbols, - substitutions_sort_key, - sympy_symbols, - sympy_to_latex, - transform_unicode_greek_symbols, -) - - -class TestConvertUnicodeDashes: - - @pytest.mark.parametrize( - "expr, expected", - [ - # No dashes at all - ("x+y", []), - # ASCII hyphen-minus is not a unicode dash — no substitution - ("x-y", []), - # Empty string - ("", []), - # HYPHEN (U+2010) - ("x‐y", [("‐", "-")]), - # NON-BREAKING HYPHEN (U+2011) - ("x‑y", [("‑", "-")]), - # FIGURE DASH (U+2012) - ("x‒y", [("‒", "-")]), - # EN DASH (U+2013) - ("x–y", [("–", "-")]), - # EM DASH (U+2014) - ("x—y", [("—", "-")]), - # MINUS SIGN (U+2212) - ("x−y", [("−", "-")]), - # SMALL HYPHEN-MINUS (U+FE63) - ("x﹣y", [("﹣", "-")]), - # FULLWIDTH HYPHEN-MINUS (U+FF0D) - ("x-y", [("-", "-")]), - # Multiple different unicode dashes in one expression - ("x–y−z", [("–", "-"), ("−", "-")]), - # Repeated occurrences of the same dash — still one substitution pair - ("x−y−z", [("−", "-")]), - ] - ) - def test_convert_unicode_dashes(self, expr, expected): - result = convert_unicode_dashes(expr) - assert result == expected - - -class TestConvertAbsoluteNotation: - - @pytest.mark.parametrize( - "expr, expected_expr, has_feedback", - [ - # No pipes — unchanged - ("x+y", "x+y", False), - # Exactly two pipes — simple conversion - ("|x|", "Abs(x)", False), - ("|x+y|", "Abs(x+y)", False), - # Two non-adjacent absolute values - ("|x|+|y|", "Abs(x)+Abs(y)", False), - ] - ) - def test_convert_absolute_notation(self, expr, expected_expr, has_feedback): - result_expr, feedback = convert_absolute_notation(expr, "response") - assert result_expr == expected_expr - assert (feedback is not None) == has_feedback - - def test_ambiguous_pipes_produce_feedback(self): - # More than 2 pipes with ambiguous positions produces feedback - expr, feedback = convert_absolute_notation("|x|y|z|", "response") - assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - - -class TestTransformUnicodeGreekSymbols: - - def test_no_greek_symbols_returns_empty(self): - assert transform_unicode_greek_symbols("x + y") == [] - - def test_named_greek_symbol_returns_self_substitution(self): - result = transform_unicode_greek_symbols("alpha + 1") - assert ("alpha", " alpha ") in result - - def test_unicode_greek_alias_maps_to_name(self): - # α is an alias for "alpha" - result = transform_unicode_greek_symbols("α") - assert ("α", " alpha ") in result - - def test_multiple_greek_symbols(self): - result = transform_unicode_greek_symbols("alpha + beta") - assert ("alpha", " alpha ") in result - assert ("beta", " beta ") in result - - def test_unicode_beta_alias(self): - result = transform_unicode_greek_symbols("β") - assert ("β", " beta ") in result - - -class TestProtectElementaryFunctionsSubstitutions: - - def test_no_functions_returns_empty(self): - assert protect_elementary_functions_substitutions("x + y") == [] - - def test_sin_generates_self_substitution(self): - result = protect_elementary_functions_substitutions("sin(x)") - assert ("sin", " sin ") in result - - def test_alias_maps_to_canonical_name(self): - # arctan is an alias for atan - result = protect_elementary_functions_substitutions("arctan(x)") - assert ("arctan", " atan ") in result - - def test_multiple_functions(self): - result = protect_elementary_functions_substitutions("sin(x) + cos(x)") - assert ("sin", " sin ") in result - assert ("cos", " cos ") in result - - -class TestSubstituteInputSymbols: - - def test_plain_expression_unchanged(self): - result = substitute_input_symbols("x+y", {}) - assert result == ["x+y"] - - def test_lambda_replaced_with_lamda(self): - result = substitute_input_symbols("lambda", {}) - assert result == ["lamda"] - - def test_alias_replaced_with_symbol_code(self): - params = {"symbols": {"x": {"latex": r"\(x\)", "aliases": ["x_var"]}}} - result = substitute_input_symbols(["x_var"], params) - assert result == ["x"] - - def test_symbol_code_preserved(self): - params = {"symbols": {"x": {"latex": r"\(x\)", "aliases": ["x_var"]}}} - result = substitute_input_symbols(["x"], params) - assert result == ["x"] - - def test_list_input_accepted(self): - result = substitute_input_symbols(["x", "y"], {}) - assert result == ["x", "y"] - - -class TestFindMatchingParenthesis: - - @pytest.mark.parametrize( - "string, index, delimiters, expected", - [ - # Simple pair - ("(x)", 0, None, 2), - # Longer content - ("(x+y)", 0, None, 4), - # Nested — outer pair - ("((x+y))", 0, None, 6), - # Nested — inner pair - ("((x+y))", 1, None, 5), - # No closing delimiter - ("(x", 0, None, -1), - # Custom square-bracket delimiters - ("[x+y]", 0, ("[", "]"), 4), - # Starts mid-string - ("a(b+c)d", 1, None, 5), - ] - ) - def test_find_matching_parenthesis(self, string, index, delimiters, expected): - if delimiters is None: - result = find_matching_parenthesis(string, index) - else: - result = find_matching_parenthesis(string, index, delimiters) - assert result == expected - - -class TestHasMatchingBrackets: - - @pytest.mark.parametrize( - "expr, expected", [ - ("x+y", True), - ("(x+y)", True), - ("[x+y]", True), - ("{x+y}", True), - ("[x+(y-1)]", True), - ("{(x+1), (x-1)}", True), - # Mismatched bracket types - ("[x+y)", False), - ("(x+y]", False), - ("{x+y)", False), - ("[(x+y]", False), - # Unbalanced brackets - ("[x+y", False), - ("x+y]", False), - ("((x+y)", False), - ] - ) - def test_has_matching_brackets(self, expr, expected): - assert has_matching_brackets(expr) is expected - - -class TestConvertBracketNotation: - - @pytest.mark.parametrize( - "expr, expected", [ - ("[x+y]", "(x+y)"), - ("[x+(y-1)]", "(x+(y-1))"), - ("{x+1}*{x-2}", "(x+1)*(x-2)"), - ] - ) - def test_matched_brackets_are_converted(self, expr, expected): - result, feedback = convert_bracket_notation(expr) - assert result == expected - assert feedback is None - - def test_multiple_answers_wrapper_left_untouched(self): - result, feedback = convert_bracket_notation("{x+1, x-1}") - assert result == "{x+1, x-1}" - assert feedback is None - - @pytest.mark.parametrize( - "expr", [ - "[x+y)", - "(x+y]", - "{x+y)", - "[x+y", - "x+y]", - ] - ) - def test_mismatched_brackets_are_rejected(self, expr): - result, feedback = convert_bracket_notation(expr) - assert result == expr - assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" - - -class TestSubstitute: - - @pytest.mark.parametrize( - "string, substitutions, expected", - [ - # Empty substitutions — unchanged - ("hello", [], "hello"), - # Single substitution - ("hello world", [("world", "earth")], "hello earth"), - # Whole string replaced - ("abc", [("abc", "xyz")], "xyz"), - # No match — unchanged - ("hello", [("world", "earth")], "hello"), - # Earlier substitution in list wins at the same position - ("abc", [("a", "p"), ("ab", "q")], "pbc"), - # Longer substitution listed first wins - ("abc", [("ab", "q"), ("a", "p")], "qc"), - # Multiple non-overlapping substitutions - ("a b c", [("a", "x"), ("b", "y"), ("c", "z")], "x y z"), - # List input is joined into a single result - (["hello", " ", "world"], [("world", "earth")], "hello earth"), - ] - ) - def test_substitute(self, string, substitutions, expected): - assert substitute(string, substitutions) == expected - - def test_lookahead_tuple_matches_with_following_context(self): - # (("sin", ["("]), " sin ") matches "sin(" but not "sin " or "sinx" - subs = [(("sin", ["("]), " sin ")] - assert substitute("sin(x)", subs) == " sin (x)" - - def test_lookahead_tuple_does_not_match_without_context(self): - subs = [(("sin", ["("]), " sin ")] - assert substitute("sinx", subs) == "sinx" - - -class TestComputeRelativeTolerance: - - @pytest.mark.parametrize( - "string, expected", - [ - # Non-numeric → 0 - ("not_a_number", 0), - # 1 sig char, below DEFAULT_SIGNIFICANT_FIGURES=2 floor → 5e-2 - ("1", 5e-2), - # "0.5" → chars "05" → lstrip "5" → len 1 → floor applies → 5e-2 - ("0.5", 5e-2), - # "1.5" → chars "15" → len 2 → max(2,2)=2 → 5e-2 - ("1.5", 5e-2), - # "100" → chars "100" (lstrip removes nothing, '1' stops it) → len 3 → 5e-3 - ("100", 5e-3), - # "1.23" → chars "123" (decimal removed) → len 3 → 5e-3 - ("1.23", 5e-3), - # Scientific notation: mantissa "1.23" → len 3 → 5e-3 - ("1.23e5", 5e-3), - # Negative: "-1.23" → lstrip removes "-" → "123" → len 3 → 5e-3 - ("-1.23", 5e-3), - ] - ) - def test_relative_tolerance(self, string, expected): - result = compute_relative_tolerance_from_significant_decimals(string) - assert result == pytest.approx(expected) - - -class TestSympySymbols: - - def test_returns_symbol_objects(self): - result = sympy_symbols({"x": {}, "y": {}}) - assert result == {"x": Symbol("x"), "y": Symbol("y")} - - def test_empty_dict(self): - assert sympy_symbols({}) == {} - - def test_symbol_names_preserved(self): - result = sympy_symbols({"alpha": {}}) - assert result["alpha"] == Symbol("alpha") - - -class TestExtractLatex: - - @pytest.mark.parametrize( - "symbol, expected", - [ - # LaTeX delimiters removed - (r"\(x^2\)", "x^2"), - ("$x^2$", "x^2"), - ("$$x^2$$", "x^2"), - (r"\(\alpha\)", r"\alpha"), - # No delimiters — returned as-is - ("x^2", "x^2"), - ("plain", "plain"), - ] - ) - def test_extract_latex(self, symbol, expected): - assert extract_latex(symbol) == expected - - -class TestLatexSymbols: - - def test_maps_symbol_to_latex_string(self): - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = latex_symbols(syms) - assert result == {Symbol("x"): "x"} - - def test_greek_latex_preserved(self): - syms = {"alpha": {"latex": r"\(\alpha\)", "aliases": []}} - result = latex_symbols(syms) - assert result == {Symbol("alpha"): r"\alpha"} - - def test_empty_dict(self): - assert latex_symbols({}) == {} - - -class TestSympyToLatex: - - def test_simple_power(self): - expr = Symbol("x") ** 2 - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == "x^{2}" - - def test_custom_latex_name_used(self): - expr = Symbol("alpha") - syms = {"alpha": {"latex": r"\(\alpha\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == r"\alpha" - - def test_sqrt(self): - expr = sqrt(Symbol("x")) - syms = {"x": {"latex": r"\(x\)", "aliases": []}} - result = sympy_to_latex(expr, syms) - assert result == r"\sqrt{x}" - - -class TestSubstitutionsSortKey: - - def test_longer_left_element_sorts_first(self): - long_sub = ("abc", "p") - short_sub = ("ab", "p") - assert substitutions_sort_key(long_sub) < substitutions_sort_key(short_sub) - - def test_equal_left_length_longer_right_sorts_first(self): - long_right = ("ab", "pqr") - short_right = ("ab", "p") - assert substitutions_sort_key(long_right) < substitutions_sort_key(short_right) - - def test_sort_orders_longer_substitutions_first(self): - subs = [("a", "x"), ("abc", "y"), ("ab", "z")] - subs.sort(key=substitutions_sort_key) - assert subs[0][0] == "abc" - assert subs[1][0] == "ab" - assert subs[2][0] == "a" - - -class TestCreateExpressionSet: - - def test_plain_string_wrapped_in_list(self): - result = create_expression_set("x+y", {}) - assert result == ["x+y"] - - def test_set_notation_split_into_list(self): - result = create_expression_set("{x, y}", {}) - assert sorted(result) == ["x", "y"] - - def test_list_input_accepted(self): - result = create_expression_set(["x", "y"], {}) - assert sorted(result) == ["x", "y"] - - def test_plus_minus_expands_to_two_expressions(self): - params = {"plus_minus": "±"} - result = create_expression_set("±x", params) - assert sorted(result) == sorted(["+x", "-x"]) or sorted(result) == sorted(["x", "-x"]) - assert len(result) == 2 - - def test_curly_braces_used_for_grouping_are_not_split(self): - result = create_expression_set("{x+1}*{x-2}", {}) - assert result == ["{x+1}*{x-2}"] - - -class TestIsMultipleAnswersWrapper: - - @pytest.mark.parametrize( - "expr, expected", [ - ("{x, y}", True), - ("{x+1}", True), - ("{(x+1), (x-1)}", True), - ("x+y", False), - ("{x+1}*{x-2}", False), - ("{x+1}*x", False), - ] - ) - def test_is_multiple_answers_wrapper(self, expr, expected): - assert is_multiple_answers_wrapper(expr) is expected - - -class TestPreprocessExpression: - - def test_plain_expression_succeeds(self): - success, expr, feedback = preprocess_expression("response", "x+y", {}) - assert success is True - assert expr == "x+y" - assert feedback is None - - def test_absolute_value_notation_converted(self): - success, expr, feedback = preprocess_expression("response", "|x|", {}) - assert success is True - assert expr == "Abs(x)" - assert feedback is None - - def test_ambiguous_pipes_returns_failure(self): - success, expr, feedback = preprocess_expression("response", "|x|y|z|", {}) - assert success is False - assert feedback is not None - assert feedback[0] == "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - - def test_square_brackets_converted(self): - success, expr, feedback = preprocess_expression("response", "[x+y]", {}) - assert success is True - assert expr == "(x+y)" - assert feedback is None - - @pytest.mark.parametrize( - "expr", [ - "[x+y)", - "(x+y]", - "{x+y)", - "[x+y", - "x+y]", - ] - ) - def test_mismatched_brackets_returns_failure(self, expr): - success, result, feedback = preprocess_expression("response", expr, {}) - assert success is False - assert result == expr - assert feedback is not None - assert feedback[0] == "BRACKET_NOTATION_MISMATCH" \ No newline at end of file diff --git a/app/tests/multi_character_test.py b/app/tests/multi_character_test.py deleted file mode 100644 index ed072e9..0000000 --- a/app/tests/multi_character_test.py +++ /dev/null @@ -1,223 +0,0 @@ -import pytest -from ..utility.expression_utilities import parse_expression, create_sympy_parsing_params - -class TestMultiCharImplicitMultiHigherPrecedenceIntegration: - """ - Integration tests for multi-characters symbols and the convention of implicit_higher_precedence enabled. - """ - - def assert_expression_equality(self, response, answer, symbols_dict, local_dict=None): - """ - Helper to parse both response and answer with the same parameters - and assert they result in equivalent SymPy expressions. - """ - if local_dict is None: - local_dict = {} - - # define parameters - params = { - "complexNumbers": False, - "strict_syntax": False, - "elementary_functions": True, - "convention": "implicit_higher_precedence", - "symbols": symbols_dict, - } - - parsing_params = create_sympy_parsing_params(params) - - try: - parsed_response = parse_expression(response, parsing_params) - parsed_answer = parse_expression(answer, parsing_params) - except Exception as e: - pytest.fail(f"Parsing failed for input '{response}' or '{answer}': {str(e)}") - - assert parsed_response == parsed_answer, \ - f"\nInput: {response}\nExpected: {parsed_answer}\nGot: {parsed_response}" - - def test_multi_character_implicit_multi_variable(self): - symbols = { - "a": {"aliases": ["a"]}, - "bc": {"aliases": ["bc"]}, - "d": {"aliases": ["d"]} - } - answer = "a/(bc*d)" - - # Case 1: Full explicit - self.assert_expression_equality("a/(bc*d)", answer, symbols) - - # Case 2: Implicit with brackets - self.assert_expression_equality("a/(bcd)", answer, symbols) - - # Case 3: Implicit no brackets - self.assert_expression_equality("a/bcd", answer, symbols) - - def test_multiple_divisions_with_implicit(self): - """Test multiple divisions in sequence""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]} - } - - answer = "a/(b*c)/(d*f)" - response = "a/bc/df" - self.assert_expression_equality(response, answer, symbols) - - def test_addition_with_division_and_implicit(self): - """Test that addition doesn't interfere""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]} - } - answer = "a/(b*c) + d" - response = "a/bc + d" - self.assert_expression_equality(response, answer, symbols) - - def test_multiplication_after_division(self): - """Test explicit multiplication after division""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]} - } - - answer = "(a/(b*c))*d" - response = "a/bc*d" - self.assert_expression_equality(response, answer, symbols) - - def test_power_with_implicit_multiplication(self): - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - } - answer = "a/(b**2*c)" - response = "a/b^2c" - self.assert_expression_equality(response, answer, symbols) - - def test_longer_multi_character_symbols(self): - symbols = { - "x": {"aliases": ["x"]}, - "abc": {"aliases": ["abc"]}, - "df": {"aliases": ["df"]}, - } - answer = "x/(abc*df)" - test_cases = [ - "x/(abc*df)", - "x/(abcdf)", - "x/abcdf", - ] - for response in test_cases: - self.assert_expression_equality(response, answer, symbols) - - def test_overlapping_symbol_names(self): - symbols = { - "a": {"aliases": ["a"]}, - "ab": {"aliases": ["ab"]}, - "abc": {"aliases": ["abc"]}, - "c": {"aliases": ["c"]}, - } - answer = "a/(abc*c)" - response = "a/abcc" - self.assert_expression_equality(response, answer, symbols) - - def test_numbers_with_implicit_multiplication(self): - """Test with numeric literals""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - } - - answer = "a/(2*b)" - response = "a/2b" - self.assert_expression_equality(response, answer, symbols) - - def test_number_variable_number(self): - """Test number followed by variable followed by number""" - symbols = { - "x": {"aliases": ["x"]}, - } - - answer = "1/(2*x*3)" - response = "1/2x3" - self.assert_expression_equality(response, answer, symbols) - - def test_implicit_before_parentheses(self): - """Test implicit multiplication before parentheses""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - } - - answer = "a*(b*c*d)" - response = "a(bcd)" - self.assert_expression_equality(response, answer, symbols) - - def test_implicit_before_parentheses_with_multichar(self): - """Test implicit multiplication before parentheses with multi-char symbols""" - symbols = { - "a": {"aliases": ["a"]}, - "bc": {"aliases": ["bc"]}, - "d": {"aliases": ["d"]}, - } - - answer = "a*(bc*d)" - response = "a(bcd)" - self.assert_expression_equality(response, answer, symbols) - - def test_complex_expression(self): - """Test a complex expression""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]}, - "g": {"aliases": ["g"]}, - } - answer = "a/(b*c) + d/(g*f)" - response = "a/bc + d/gf" - self.assert_expression_equality(response, answer, symbols) - - def test_three_way_implicit_multiplication(self): - """Test three or more implicitly multiplied terms""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - "f": {"aliases": ["f"]}, - } - answer = "a/(b*c*d*f)" - response = "a/bcdf" - self.assert_expression_equality(response, answer, symbols) - - def test_explicit_multiplication_should_not_be_grouped(self): - """Test that explicit multiplication maintains standard precedence""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - } - answer = "(a/b)*c" - response = "a/b*c" - self.assert_expression_equality(response, answer, symbols) - - def test_mixed_implicit_and_explicit(self): - """Test mixing implicit and explicit multiplication""" - symbols = { - "a": {"aliases": ["a"]}, - "b": {"aliases": ["b"]}, - "c": {"aliases": ["c"]}, - "d": {"aliases": ["d"]}, - } - answer = "(a/(b*c))*d" - response = "a/bc*d" - self.assert_expression_equality(response, answer, symbols) \ No newline at end of file diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index 6d1f635..a3bdc78 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -2,14 +2,15 @@ import os # Import necessary data and reference cases for tests -from .slr_quantity_test import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases +from .physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases from ..evaluation import evaluation_function -from ..utility.unit_system_conversions import ( - set_of_SI_prefixes, - set_of_SI_base_unit_dimensions, - set_of_derived_SI_units_in_SI_base_units, - set_of_common_units_in_SI, set_of_very_common_units_in_SI, - set_of_imperial_units +from compareexpressions.units import ( + COMMON_UNITS, + IMPERIAL_UNITS, + SI_BASE_UNITS, + SI_DERIVED_UNITS, + SI_PREFIXES, + VERY_COMMON_UNITS, ) @@ -31,9 +32,6 @@ class TestEvaluationFunction(): Use evaluation_function() to call the evaluation function. """ - # Import tests that makes sure that physical quantity parsing works as expected - from .slr_quantity_test import TestEvaluationFunction as TestStrictSLRSyntax - log_details = True def log_details_to_file(self, details, filename): @@ -76,12 +74,12 @@ def test_natural_syntax_cases(self, string, value, unit, content, value_latex, u @pytest.mark.skip("Too resource intensive") def test_slow_quantity_alternative_names_natural_syntax(self): params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI common imperial", "strictness": "natural"} - units = set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units + units = SI_BASE_UNITS + SI_DERIVED_UNITS + COMMON_UNITS + VERY_COMMON_UNITS + IMPERIAL_UNITS incorrect = [] errors = [] n = 0 k = 0 - for prefix in set_of_SI_prefixes: + for prefix in SI_PREFIXES: for u1 in units: for u2 in units: if u1 is not u2: @@ -110,12 +108,12 @@ def test_slow_quantity_alternative_names_natural_syntax(self): @pytest.mark.skip("Too resource intensive") def test_slow_quantity_short_forms_natural_syntax(self): params = {"strict_syntax": False, "physical_quantity": True, "units_string": "SI common imperial", "strictness": "natural"} - units = set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units + units = SI_BASE_UNITS + SI_DERIVED_UNITS + COMMON_UNITS + VERY_COMMON_UNITS + IMPERIAL_UNITS incorrect = [] errors = [] n = 0 k = 0 - for prefix in set_of_SI_prefixes: + for prefix in SI_PREFIXES: for u1 in units: for u2 in units: if u1 is not u2 and (prefix[1]+u1[1] not in [u[1] for u in units]): diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py deleted file mode 100644 index 147cf69..0000000 --- a/app/tests/physical_quantity_preview_test.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -import pytest - -from ..preview_implementations.physical_quantity_preview import preview_function -from .slr_quantity_test import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases - - -class TestPreviewFunction(): - """ - TestCase Class used to test the algorithm. - --- - Tests are used here to check that the algorithm written - is working as it should. - - It's best practise to write these tests first to get a - kind of 'specification' for how your algorithm should - work, and you should run these tests before committing - your code to AWS. - - Read the docs on how to use unittest here: - https://docs.python.org/3/library/unittest.html - - Use preview_function() to check your algorithm works - as it should. - """ - - @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", - "elementary_functions": True - } - result = preview_function(response, params)["preview"] - latex = "" - if value_latex is None and unit_latex is not None: - latex = unit_latex - elif value_latex is not None and unit_latex is None: - latex = value_latex - elif value_latex is not None and unit_latex is not None: - latex = value_latex+"~"+unit_latex - assert result["latex"] == 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", - "elementary_functions": True - } - result = preview_function(response, params)["preview"] - latex = "" - if value_latex is None and unit_latex is not None: - latex = unit_latex - elif value_latex is not None and unit_latex is None: - latex = value_latex - elif value_latex is not None and unit_latex is not None: - latex = value_latex+"~"+unit_latex - assert result["latex"] == latex - - @pytest.mark.parametrize( - "response,preview_latex,preview_sympy", - [ - ("sin(123)", r"\sin{\left(123 \right)}", "sin(123)"), - ("sqrt(162)", r"\sqrt{162}", "sqrt(162)"), - ] - ) - def test_issue_with_function_name_that_can_be_compound_unit(self, response, preview_latex, preview_sympy): - params = { - "physical_quantity": True, - "elementary_functions": True, - } - result = preview_function(response, params)["preview"] - assert result["latex"] == preview_latex - assert result["sympy"] == preview_sympy - - def test_handwritten_input(self): - params = { - "is_latex": True, - "physical_quantity": True, - "elementary_functions": True, - } - response = "162 \\mathrm{~N} / \\mathrm{m}^{2}" - result = preview_function(response, params)["preview"] - assert result["latex"] == r'162~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}' # TODO: Fix so that unnecessary parenthesis are simplified away - assert result["sympy"] == "162 newton/metre**(2)" - - @pytest.mark.parametrize( - "response, latex, sympy", [ - (r"\frac{F}{p \cdot \mu}", r"\frac{F}{\mu p}", "F/((mu*p))"), - (r"F \cdot p", "F p", "F*p"), - (r"\frac{10}{2} \mathrm{~kg}", r"\frac{10}{2}~\mathrm{kilogram}", "10/2 kilogram"), - ] - ) - def test_latex_physical_quantity_preview(self, response, latex, sympy): - params = { - "is_latex": True, - "physical_quantity": True, - "elementary_functions": True, - } - result = preview_function(response, params)["preview"] - assert result["latex"] == latex - assert result["sympy"] == sympy - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/slr_quantity_test.py b/app/tests/physical_quantity_test_cases.py similarity index 70% rename from app/tests/slr_quantity_test.py rename to app/tests/physical_quantity_test_cases.py index 5f0ac5a..26fd31c 100644 --- a/app/tests/slr_quantity_test.py +++ b/app/tests/physical_quantity_test_cases.py @@ -1,10 +1,6 @@ -import pytest -import os - -from ..context.physical_quantity import SLR_quantity_parser, SLR_quantity_parsing, default_parameters -from ..utility.unit_system_conversions import\ - set_of_SI_base_unit_dimensions, set_of_derived_SI_units_in_SI_base_units,\ - set_of_common_units_in_SI, set_of_very_common_units_in_SI, set_of_imperial_units +# Physical quantities with their expected value, unit and LaTeX forms, shared by +# the physical quantity evaluation tests. The quantity parser itself is tested in +# the compareexpressions-units package. slr_strict_si_syntax_test_cases = [ ("q", # String that will be used as response / answer @@ -447,130 +443,3 @@ r"\frac{1}{\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ] - - -class TestEvaluationFunction(): - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) - def test_strict_si_syntax(self, string, value, unit, content, value_latex, unit_latex, criteria): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI", - "strictness": "strict", - "elementary_functions": True, - } - ) - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(string, parameters, parser, "quantity") - parsed_value = quantity.value.original_string() if quantity.value is not None else None - parsed_unit = quantity.unit.original_string() if quantity.unit is not None else None - parsed_value_latex = quantity.value_latex_string - parsed_unit_latex = quantity.unit_latex_string - parsed_content = quantity.ast_root.content_string() - assert parsed_value == value - assert parsed_unit == unit - assert parsed_content == content - assert parsed_value_latex == value_latex - assert parsed_unit_latex == unit_latex - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units] - ) - def test_short_forms_strict_SI(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI] - ) - def test_short_forms_common_SI(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "common", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_imperial_units] - ) - def test_short_forms_imperial(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "imperial", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize( - "long_form,short_form", - [(x[0], x[1]) for x in set_of_SI_base_unit_dimensions | set_of_derived_SI_units_in_SI_base_units | set_of_common_units_in_SI | set_of_very_common_units_in_SI | set_of_imperial_units] - ) - def test_short_forms_all(self, long_form, short_form): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI common imperial", - "strictness": "strict" - } - ) - parser = SLR_quantity_parser(parameters) - long_quantity = SLR_quantity_parsing(long_form, parameters, parser, "quantity") - short_quantity = SLR_quantity_parsing(short_form, parameters, parser, "quantity") - assert long_quantity.unit.content_string() == short_quantity.unit.content_string() - - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) - def test_natural_si_syntax(self, string, value, unit, content, value_latex, unit_latex, criteria): - parameters = default_parameters - parameters.update( - { - "strict_syntax": False, - "units_string": "SI common imperial", - "strictness": "natural", - "elementary_functions": True - } - ) - parser = SLR_quantity_parser(parameters) - quantity = SLR_quantity_parsing(string, parameters, parser, "quantity") - parsed_unit_latex = quantity.unit_latex_string - parsed_value = quantity.value.original_string() if quantity.value is not None else None - parsed_unit = quantity.unit.original_string() if quantity.unit is not None else None - parsed_value_latex = quantity.value_latex_string - parsed_unit_latex = quantity.unit_latex_string - parsed_content = quantity.ast_root.content_string() - assert parsed_value == value - assert parsed_unit == unit - assert parsed_content == content - assert parsed_value_latex == value_latex - assert parsed_unit_latex == unit_latex - - -if __name__ == "__main__": - pytest.main(["-xs", "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 89243cb..7922f9c 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -2,7 +2,8 @@ import os from ..evaluation import evaluation_function -from ..utility.expression_utilities import elementary_functions_names, substitute +from compareexpressions.expression_parsing import substitute +from compareexpressions.expression_parsing.symbol_tables import ELEMENTARY_FUNCTIONS as elementary_functions_names # REMARK: If a case is marked with an alternative output, this means that it is difficult in this case to prevent sympy from simplifying for that particular case elementary_function_test_cases = [ diff --git a/app/tests/symbolic_preview_test.py b/app/tests/symbolic_preview_test.py deleted file mode 100644 index cb86d62..0000000 --- a/app/tests/symbolic_preview_test.py +++ /dev/null @@ -1,258 +0,0 @@ -import os -import pytest - -from ..utility.preview_utilities import Params, extract_latex -from ..preview_implementations.symbolic_preview import preview_function -from .symbolic_evaluation_test import elementary_function_test_cases - - -class TestPreviewFunction(): - """ - TestCase Class used to test the algorithm. - --- - Tests are used here to check that the algorithm written - is working as it should. - - It's best practise to write these tests first to get a - kind of 'specification' for how your algorithm should - work, and you should run these tests before committing - your code to AWS. - - Read the docs on how to use unittest here: - https://docs.python.org/3/library/unittest.html - - Use preview_function() to check your algorithm works - as it should. - """ - - def test_doesnt_simplify_latex_by_default(self): - response = "\\frac{x + x^2 + x}{x}" - params = Params(is_latex=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("sympy") == "(x**2 + x + x)/x" - - def test_doesnt_simplify_sympy_by_default(self): - response = "(x + x**2 + x)/x" - params = Params(is_latex=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\frac{x^{2} + x + x}{x}" - - def test_simplifies_latex_on_param(self): - response = "\\frac{x + x^2 + x}{x}" - params = Params(is_latex=True, simplify=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("sympy") == "x + 2" - - def test_simplifies_sympy_on_param(self): - response = "(x + x**2 + x)/x" - params = Params(is_latex=False, simplify=True) - result = preview_function(response, params) - preview = result["preview"] - - assert preview.get("latex") == "x + 2" - - def test_sympy_handles_implicit_multiplication(self): - response = "sin(x) + cos(2x) - 3x**2" - params = Params(is_latex=False, strict_syntax=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == r"- 3 \cdot x^{2} + \sin{\left(x \right)} + \cos{\left(2 \cdot x \right)}" - - def test_latex_with_equality_symbol(self): - response = "\\frac{x + x^2 + x}{x} = y" - params = Params(is_latex=True, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") == '(x**2 + x + x)/x=y' - - def test_sympy_with_equality_symbol(self): - response = "Eq((x + x**2 + x)/x, 1)" - params = Params(is_latex=False, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\frac{x^{2} + x + x}{x} = 1" - - def test_latex_with_plus_minus(self): - response = r"\pm \frac{3}{\sqrt{5}} i" - params = Params( - is_latex=True, - simplify=False, - complexNumbers=True, - symbols={ - "I": { - "latex": "$i$", - "aliases": ["i"], - }, - "plus_minus": { - "latex": "$\\pm$", - "aliases": ["pm", "+-"], - }, - } - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in {'{3*(sqrt(5)/5)*I, -3*sqrt(5)/5*I}', '{-3*sqrt(5)/5*I, 3*(sqrt(5)/5)*I}'} - assert preview.get("latex") == r'\pm \frac{3}{\sqrt{5}} i' - response = r"4 \pm \sqrt{6}}" - params = Params( - is_latex=True, - simplify=False, - complexNumbers=True, - symbols={ - "plus_minus": { - "latex": "$\\pm$", - "aliases": ["pm", "+-"], - }, - } - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in {'{sqrt(6) + 4, 4 - sqrt(6)}', '{4 - sqrt(6), sqrt(6) + 4}'} - assert preview.get("latex") == r'4 \pm \sqrt{6}}' - - def test_latex_conversion_preserves_default_symbols(self): - response = "\\mu + x + 1" - params = Params(is_latex=True, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") in "mu + x + 1" - - def test_sympy_conversion_preserves_default_symbols(self): - response = "mu + x + 1" - params = Params(is_latex=False, simplify=False) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "\\mu + x + 1" - - def test_latex_conversion_preserves_optional_symbols(self): - response = "m_{ \\text{table} } + \\text{hello}_\\text{world} - x + 1" - params = Params( - is_latex=True, - simplify=False, - symbols={ - "m_table": { - "latex": r"hello \( m_{\text{table}} \) world", - "aliases": [], - }, - "test": { - "latex": r"hello $ \text{hello}_\text{world} $ world.", - "aliases": [], - }, - }, - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("sympy") == "m_table + test - x + 1" - - def test_sympy_conversion_preserves_optional_symbols(self): - response = "m_table + test + x + 1" - params = Params( - is_latex=False, - simplify=False, - symbols={ - "m_table": {"latex": "m_{\\text{table}}", "aliases": []}, - "test": { - "latex": "\\text{hello}_\\text{world}", - "aliases": [], - }, - }, - ) - result = preview_function(response, params) - preview = result["preview"] - assert preview.get("latex") == "m_{\\text{table}} + \\text{hello}_\\text{world} + x + 1" - - def test_invalid_latex_returns_error(self): - response = "\frac{ m_{ \\text{table} } + x + 1 }{x" - params = Params( - is_latex=True, - simplify=False, - symbols={"m_table": {"latex": "m_{\\text{table}}", "aliases": []}}, - ) - - with pytest.raises(ValueError): - preview_function(response, params) - - def test_invalid_sympy_returns_error(self): - response = "x + x***2 - 3 / x 4" - params = Params(simplify=False, is_latex=False) - - with pytest.raises(ValueError): - preview_function(response, params) - - def test_extract_latex_in_delimiters(self): - parentheses = r"\( x + 1 \)" - dollars = r"$ x ** 2 + 1 $" - double_dollars = r"$$ \sin x + \tan x $$" - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - assert extract_latex(double_dollars) == r" \sin x + \tan x " - - def test_extract_latex_in_delimiters_and_text(self): - parentheses = r"hello \( x + 1 \) world." - dollars = r"hello $ x ** 2 + 1 $ world." - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - - def test_extract_latex_no_delimiters(self): - test = r"'\sin x + \left ( \text{hello world} \right ) + \cos x" - assert extract_latex(test) == test - - def test_extract_latex_multiple_expressions(self): - parentheses = r"hello \( x + 1 \) world. \( \sin x + \cos x \) yes." - dollars = r"hello $ x ** 2 + 1 $ world. \( \sigma \times \alpha \) no." - mixture = r"hello $ x ** 2 - 1 $ world. $ \sigma \times \alpha $ !." - - assert extract_latex(parentheses) == " x + 1 " - assert extract_latex(dollars) == " x ** 2 + 1 " - assert extract_latex(mixture) == " x ** 2 - 1 " - - def test_elementary_functions_preview(self): - params = {"strict_syntax": False, "elementary_functions": True} - for case in elementary_function_test_cases: - response = case[1] - result = preview_function(response, params) - assert result["preview"]["latex"] == case[3] - - def test_implicit_multiplication_convention_implicit_higher_precedence(self): - response = "1/ab" - latex = r"1 \cdot \frac{1}{a \cdot b}" # REMARK: Here it would be preferable to not have the '1 \cdot ' at the start - params = {"strict_syntax": False, "convention": "implicit_higher_precedence"} - result = preview_function(response, params) - assert result["preview"]["latex"] == latex - - def test_implicit_multiplication_convention_equal_precedence(self): - latex = r"1 \cdot \frac{1}{a} \cdot b" # REMARK: Here it would be preferable to not have the '1 \cdot ' at the start - params = {"strict_syntax": False, "convention": "equal_precedence"} - response_a = "1/ab" - result_a = preview_function(response_a, params) - assert result_a["preview"]["latex"] == latex - response_b = "1/a*b" - result_b = preview_function(response_b, params) - assert result_b["preview"]["latex"] == latex - - def test_MECH50001_2_24_a(self): - params = { - "strict_syntax": False, - "elementary_functions": True, - 'symbols': { - 'alpha': {'aliases': [], 'latex': r'\alpha'}, - 'Derivative(q,t)': {'aliases': ['q_{dot}', 'q_dot'], 'latex': r'\dot{q}'}, - 'Derivative(T,t)': {'aliases': ['dT/dt'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}t}'}, - 'Derivative(T,x)': {'aliases': ['dT/dx'], 'latex': r'\frac{\mathrm{d}T}{\mathrm{d}x}'}, - 'Derivative(T,x,x)': {'aliases': ['(d^2 T)/(dx^2)', 'd^2 T/dx^2', 'd^2T/dx^2'], 'latex': r'\frac{\mathrm{d}^2 T}{\mathrm{d}x^2}'}, - }, - } - response = "d^2T/dx^2 + q_dot/k = 1/alpha*(dT/dt)" - result = preview_function(response, params) - assert result["preview"]["latex"] == r'\frac{d^{2}}{d x^{2}} T + \frac{\frac{d}{d t} q}{k}=1 \cdot \frac{1}{\alpha} \cdot \frac{d}{d t} T' - - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/syntactical_comparison_utilities_test.py b/app/tests/syntactical_comparison_utilities_test.py deleted file mode 100644 index df62243..0000000 --- a/app/tests/syntactical_comparison_utilities_test.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from ..utility.syntactical_comparison_utilities import generate_arbitrary_number_pattern_matcher - - -class TestGenerateArbitraryNumberPatternMatcher: - - @pytest.mark.parametrize( - "pattern_string, matching_strings, non_matching_strings", - [ - ("2x+3", ["2x+3", "5x+7"], ["2x-3", "2x+3+1"]), - # Regression test: the text following the last matched number - # (here the closing bracket ")") used to be inserted into the - # generated regex unescaped and unwrapped, which could produce - # an invalid pattern and raise `re.error: unbalanced parenthesis`. - ("(2x-5)(3x+2)", ["(2x-5)(3x+2)", "(7x-1)(4x+9)"], ["(3x+2)(2x-5)", "3x^2-11x-10"]), - ("(x-4)^2-5", ["(x-4)^2-5", "(x-6)^2-1"], ["(x-4)^2+5", "x^2-8x+11"]), - ] - ) - def test_matcher(self, pattern_string, matching_strings, non_matching_strings): - matcher = generate_arbitrary_number_pattern_matcher(pattern_string) - for matching_string in matching_strings: - assert matcher(matching_string) is True - for non_matching_string in non_matching_strings: - assert matcher(non_matching_string) is False diff --git a/app/utility/__init__.py b/app/utility/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py deleted file mode 100644 index 50656da..0000000 --- a/app/utility/expression_utilities.py +++ /dev/null @@ -1,868 +0,0 @@ -# Default parameters for expression handling -# Any contexts that use this collection of utility functions -# must define values for theses parameters - - -DEFAULT_SIGNIFICANT_FIGURES = 2 - -default_parameters = { - "complexNumbers": False, - "convention": "equal_precedence", - "elementary_functions": False, - "strict_syntax": False, - "multiple_answers_criteria": "all", -} - -# -------- String Manipulation imports -from .slr_parsing_utilities import ( - SLR_expression_parser, - infix, - group, - compose -) -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, 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. - """ - def _print_log(self, expr, exp=None): - if self._settings["ln_notation"] and len(expr.args) < 2: - log_not = r"\ln" - else: - log_not = r"\log" - if len(expr.args) > 1: - base = self._print(expr.args[1]) - log_not = r"\log_{%s}" % base - tex = r"%s{\left(%s \right)}" % (log_not, self._print(expr.args[0])) - - if exp is not None: - return r"%s^{%s}" % (tex, exp) - 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']), - ('atan', ['arctan']), ('acot', ['arccot', 'arccotan', 'acotan']), ('atan2', ['arctan2']), - ('sinh', []), ('cosh', []), ('tanh', []), ('csch', ['cosech']), ('sech', []), - ('asinh', ['arcsinh']), ('acosh', ['arccosh']), ('atanh', ['arctanh']), - ('acsch', ['arccsch', 'arccosech']), ('asech', ['arcsech']), - ('exp', ['Exp']), ('E', ['e']), ('log', ['ln']), - ('sqrt', []), ('sign', []), ('Abs', ['abs']), ('Max', ['max']), ('Min', ['min']), ('arg', []), ('ceiling', ['ceil']), ('floor', []), - ('oo',['Infinity', 'inf', 'infinity', '∞']), - # 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']), ('Integral', ['int']), ('Derivative', ['diff']), ('re', ['real']), ('im', ['imag']), ('conjugate', ['conj']) -] -for data in elementary_functions_names: - upper_case_alternatives = [data[0].upper()] - for alternative in data[1]: - if alternative.upper() not in upper_case_alternatives: - upper_case_alternatives.append(alternative.upper()) - data[1].extend(upper_case_alternatives) - -special_symbols_names = [ - ("Alpha", ["Α", "𝚨", "𝛢", "𝜜", "𝝖", "𝞐"]), - ("alpha", ["α", "𝛂", "𝛼", "𝜶", "𝝰", "𝞪"]), - ("Beta", ["Β", "𝚩", "𝛣", "𝜝", "𝝗", "𝞑"]), - ("beta", ["β", "ϐ", "𝛃", "𝛽", "𝜷", "𝝱", "𝞫"]), - ("Gamma", ["Γ", "𝚪", "𝛤", "𝜞", "𝝘", "𝞒"]), - ("gamma", ["γ", "𝛄", "𝛾", "𝜸", "𝝲", "𝞬"]), - ("Delta", ["Δ", "𝚫", "𝛥", "𝜟", "𝝙", "𝞓"]), - ("delta", ["δ", "𝛅", "𝛿", "𝜹", "𝝳", "𝞭"]), - ("Epsilon", ["Ε", "𝚬", "𝛦", "𝜠", "𝝚", "𝞔"]), - ("epsilon", ["ε", "ϵ", "𝛆", "𝜀", "𝜺", "𝝴", "𝞊", "𝞮"]), - ("Zeta", ["Ζ", "𝚭", "𝛧", "𝜡", "𝝛", "𝞕"]), - ("zeta", ["ζ", "𝛇", "𝜁", "𝜻", "𝝵", "𝞯"]), - ("Eta", ["Η", "𝚮", "𝛨", "𝜢", "𝝜", "𝞖"]), - ("eta", ["η", "𝛈", "𝜂", "𝜼", "𝝶", "𝞰"]), - ("Theta", ["Θ", "ϴ", "𝚯", "𝛩", "𝜣", "𝝝", "𝞗"]), - ("theta", ["θ", "ϑ", "𝛉", "𝜃", "𝜗", "𝜽", "𝝑", "𝝷", "𝞋", "𝞱"]), - ("Iota", ["Ι", "𝚰", "𝛪", "𝜤", "𝝞", "𝞘"]), - ("iota", ["ι", "𝛊", "𝜄", "𝜾", "𝝸", "𝞲"]), - ("Kappa", ["Κ", "𝚱", "𝛫", "𝜥", "𝝟", "𝞙"]), - ("kappa", ["κ", "ϰ", "𝛋", "𝜅", "𝜘", "𝜿", "𝝒", "𝝹", "𝞌", "𝞳"]), - ("Lambda", ["Λ", "𝚲", "𝛬", "𝜦", "𝝠", "𝞚"]), - ("lamda", ["λ", "𝛌", "𝜆", "𝝀", "𝝺", "𝞴"]), # lambda mispelt here to minimise conflict with Python in-built - ("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", ["ω", "𝛚", "𝜔", "𝝎", "𝞈", "𝟂"]) -] - - - -# -------- String Manipulation Utilities -def is_multiple_answers_wrapper(expr): - """ - True if expr, as a whole, is wrapped in a top-level {...} pair that - create_expression_set will treat as a set of multiple acceptable - answers, as opposed to {} that merely sit at the string's edges - incidentally (e.g. "{x+1}*{x-2}"). - """ - stripped = expr.strip() - if not (stripped.startswith('{') and stripped.endswith('}')): - return False - return find_matching_parenthesis(stripped, 0, delimiters=('{', '}')) == len(stripped) - 1 - - -def create_expression_set(exprs, params): - if isinstance(exprs, str): - if is_multiple_answers_wrapper(exprs): - exprs = [expr.strip() for expr in exprs[1:-1].split(',')] - else: - exprs = [exprs] - expr_set = set() - 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) - - -def convert_bracket_notation(expr): - """ - Accept [] and {} as other ways of writing (), SymPy only accepts () - for grouping since [], {}, and () are otherwise reserved for lists, - sets, and tuples respectively. {} is left untouched when it spans the - entire expression, since that denotes a set of multiple acceptable - answers (see create_expression_set). - - Brackets must be closed with the matching type, e.g. "[x+y)" is - rejected rather than silently reinterpreted as "(x+y)", since this - project does not support interval/limit notation. If the brackets in - expr are mismatched, expr is returned unconverted together with - feedback describing the problem. - """ - if not has_matching_brackets(expr): - tag = "BRACKET_NOTATION_MISMATCH" - return expr, (tag, feedback_string_generators["INTERNAL"](tag)({'x': expr})) - expr = expr.replace("[", "(").replace("]", ")") - if is_multiple_answers_wrapper(expr): - return expr, None - return expr.replace("{", "(").replace("}", ")"), None - - -def convert_absolute_notation(expr, name): - """ - Accept || as another form of writing modulus of an expression. - Function makes the input parseable by SymPy, SymPy only accepts Abs() - REMARK: this function cannot handle nested ||. It will attempt to pair - each | with the closest | to the right and return a string with a warning - if it detects an ambiguity. - - Parameters - ---------- - expr : string - Expression to convert, might have || - - Returns - ------- - expr : string - converted response input - """ - - # positions of the || values - n_expr = expr.count('|') - if n_expr == 2: - expr = list(expr) - expr[expr.index("|")] = "Abs(" - expr[expr.index("|")] = ")" - expr = "".join(expr) - elif n_expr > 0: - expr_start_abs_pos = [] - expr_end_abs_pos = [] - expr_ambiguous_abs_pos = [] - - if expr[0] == "|": - expr_start_abs_pos.append(0) - for i in range(1, len(expr)-1): - if expr[i] == "|": - if (expr[i-1].isalnum() or expr[i-1] in "()[]{}") and not (expr[i+1].isalnum() or expr[i+1] in "()[]{}"): - expr_end_abs_pos.append(i) - elif (expr[i+1].isalnum() or expr[i+1] in "()[]{}") and not (expr[i-1].isalnum() or expr[i-1] in "()[]{}"): - expr_start_abs_pos.append(i) - else: - expr_ambiguous_abs_pos.append(i) - if expr[-1] == "|": - expr_end_abs_pos.append(len(expr)-1) - expr = list(expr) - for i in expr_start_abs_pos: - expr[i] = "Abs(" - for i in expr_end_abs_pos: - expr[i] = ")" - k = 0 - prev_ambiguous = -1 - for i in expr_ambiguous_abs_pos: - prev_start = -1 - for j in expr_start_abs_pos: - if j < i: - prev_start = j - else: - break - prev_end = -1 - for j in expr_end_abs_pos: - if j < i: - prev_end = j - else: - break - if max(prev_start, prev_end, prev_ambiguous) == prev_end: - if expr[i-1].isalnum(): - expr[i] = "*Abs(" - else: - expr[i] = "Abs(" - elif max(prev_start, prev_end, prev_ambiguous) == prev_ambiguous: - if k % 2 == 0: - if expr[i-1].isalnum(): - expr[i] = "*Abs(" - else: - expr[i] = "Abs(" - else: - expr[i] = ")" - k += 1 - else: - expr[i] = ")" - prev_ambiguous = i - expr = "".join(expr) - - ambiguity_tag = "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" - remark = "" - if n_expr > 2 and len(expr_ambiguous_abs_pos) > 0: - remark = feedback_string_generators["INTERNAL"](ambiguity_tag)({'name': name}) - - feedback = None - if len(remark) > 0: - feedback = (ambiguity_tag, remark) - - return expr, feedback - - -def SLR_implicit_multiplication_convention_parser(convention): - delimiters = [ - (("(", ")"), group(1)) - ] - - costum_tokens = [ - (" *(\*|\+|-| ) *", "SPLIT"), (" */ *", "SOLIDUS") - ] - - infix_operators = [] - costum_productions = [("E", "*E", group(2, empty=True)), ("E", "EE", group(2, empty=True))] - if convention == "equal_precedence": - costum_productions += [("E", "E/E", infix)] - elif convention == "implicit_higher_precedence": - costum_productions += [("E", "E/E", compose(infix, group(1, empty=True, delimiters=["(", ")"])))] - else: - raise Exception(f"Unknown convention {convention}") - - undefined = ("O", "OTHER") - expression_node = ("E", "EXPRESSION_NODE") - return SLR_expression_parser(delimiters=delimiters, infix_operators=infix_operators, undefined=undefined, expression_node=expression_node, costum_tokens=costum_tokens, costum_productions=costum_productions) - - -def preprocess_according_to_chosen_convention(expression, parameters): - convention = parameters.get("convention", None) - if convention is not None: - parser = SLR_implicit_multiplication_convention_parser(convention) - expression = parser.parse(parser.scan(expression))[0].content_string() - return expression - -def transform_unicode_greek_symbols(expr): - alias_substitutions = [] - for (name, alias_list) in 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 convert_unicode_dashes(expr): - unicode_dashes = [ - "‐", # HYPHEN - "‑", # NON-BREAKING HYPHEN - "‒", # FIGURE DASH - "–", # EN DASH - "—", # EM DASH - "−", # MINUS SIGN - "﹣", # SMALL HYPHEN-MINUS - "-", # FULLWIDTH HYPHEN-MINUS - ] - return [(dash, "-") for dash in unicode_dashes if dash in expr] - - -def protect_elementary_functions_substitutions(expr): - alias_substitutions = [] - for (name, alias_list) in elementary_functions_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: - exprs : a string or a list of strings - params : Evaluation function parameter dictionary - Output: - List of strings where alternatives for input symbols have been replaced with - their corresponsing input symbol code. - Remark: - Alternatives are sorted before substitution so that longer alternatives takes precedence. - ''' - if isinstance(exprs, str): - exprs = [exprs] - - 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 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 - - if "symbols" in params.keys(): - # Removing invalid input symbols - input_symbols_to_remove = [] - aliases_to_remove = [] - for (code, symbol_data) in input_symbols.items(): - if len(code) == 0: - input_symbols_to_remove += [code] - else: - if len(code.strip()) == 0: - input_symbols_to_remove += [code] - else: - aliases = symbol_data["aliases"] - for i in range(0, len(aliases)): - if len(aliases[i]) > 0: - aliases[i].strip() - if len(aliases[i]) == 0: - aliases_to_remove += [(code, i)] - for (code, i) in aliases_to_remove: - del input_symbols[code]["aliases"][i] - for code in input_symbols_to_remove: - del input_symbols[code] - - # Since 'lambda' is a reserved keyword in python - # it needs to be replaced with 'lamda' for expression - # parsing to work properly - lambda_value = input_symbols.pop("lambda", {"latex": r"\lambda", "aliases": ["lambda"]}) - if lambda_value is not None: - lambda_value["aliases"].append("lambda") - input_symbols.update({"lamda": lambda_value}) - params.update({"symbols": input_symbols}) - - for (code, symbol_data) in input_symbols.items(): - substitutions.append((code, code)) - for alias in symbol_data["aliases"]: - if len(alias) > 0: - substitutions.append((alias, code)) - - # REMARK: This is to ensure capability with response areas that use the old formatting - # for input_symbols. Should be removed when all response areas are updated. - if "input_symbols" in params.keys(): - input_symbols = params["input_symbols"] - input_symbols_to_remove = [] - alternatives_to_remove = [] - for k in range(0, len(input_symbols)): - if len(input_symbols[k]) > 0: - input_symbols[k][0].strip() - if len(input_symbols[k][0]) == 0: - input_symbols_to_remove += [k] - else: - for i in range(0, len(input_symbols[k][1])): - if len(input_symbols[k][1][i]) > 0: - input_symbols[k][1][i].strip() - if len(input_symbols[k][1][i]) == 0: - alternatives_to_remove += [(k, i)] - for (k, i) in alternatives_to_remove: - del input_symbols[k][1][i] - for k in input_symbols_to_remove: - del input_symbols[k] - for input_symbol in params["input_symbols"]: - substitutions.append((input_symbol[0], input_symbol[0])) - for alternative in input_symbol[1]: - 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] - - # Since 'as' is a reserved keyword in python, we add a subsitution of 'as' to 'a*s' if 'as' is not a defined symbol - if 'as' not in input_symbols: - substitutions += [('as', 'a*s')] - - substitutions = list(set(substitutions)) - if len(substitutions) > 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 has_matching_brackets(expr): - """ - True if every occurrence of (, [, { in expr is closed by the closing - bracket of the same type, correctly nested. Mismatched types (e.g. the - "[x+y)" in interval/limit notation) or unbalanced brackets are rejected. - """ - closing_to_opening = {')': '(', ']': '[', '}': '{'} - stack = [] - for char in expr: - if char in '([{': - stack.append(char) - elif char in ')]}': - if not stack or stack.pop() != closing_to_opening[char]: - return False - return not stack - - -def find_matching_parenthesis(string, index, delimiters=None): - depth = 0 - if delimiters is None: - delimiters = ('(', ')') - for k in range(index, len(string)): - if string[k] == delimiters[0]: - depth += 1 - continue - if string[k] == delimiters[1]: - depth += -1 - if depth == 0: - return k - return -1 - - -def substitute(string, substitutions): - ''' - Input: - string (required) : a string or a list of strings - substitutions (required) : a list with elements of the form (string,string) - or ((string,list of strings),string) - Output: - A string that is the input string where any occurence of the left element - of each pair in substitutions have been replaced with the corresponding right element. - If the first element in the substitution is of the form (string,list of strings) then - the substitution will only happen if the first element followed by one of the strings - in the list in the second element. - Remarks: - Substitutions are made in the input order but if a substitutions left element is a - substring of a preceding substitutions right element there will be no substitution. - In most cases it is good practice to sort the substitutions by the length of the left - element in descending order. - Examples: - substitute("abc bc c", [("abc","p"), ("bc","q"), ("c","r")]) - returns: "p q r" - substitute("abc bc c", [("c","r"), ("bc","q"), ("abc","p")]) - returns: "abr br r" - substitute("p bc c", [("p","abc"), ("bc","q"), ("c","r")]) - returns: "abc q c" - substitute("p bc c", [("c","r"), ("bc","q"), ("p","abc")]) - returns: "abc br r" - ''' - if isinstance(string, str): - string = [string] - - # Perform substitutions - new_string = [] - for part in string: - if not isinstance(part, str): - new_string.append(part) - else: - index = 0 - string_buffer = "" - while index < len(part): - matched_start = False - for k, pair in enumerate(substitutions): - if isinstance(pair[0], tuple): - match = False - for look_ahead in pair[0][1]: - if part.startswith(pair[0][0]+look_ahead, index): - match = True - break - substitution_length = len(pair[0][0]) - else: - match = part.startswith(pair[0], index) - substitution_length = len(pair[0]) - if match: - matched_start = True - if len(string_buffer) > 0: - new_string.append(string_buffer) - string_buffer = "" - new_string.append(k) - index += substitution_length - break - if not matched_start: - string_buffer += part[index] - index += 1 - if len(string_buffer) > 0: - new_string.append(string_buffer) - - for k, elem in enumerate(new_string): - if isinstance(elem, int): - new_string[k] = substitutions[elem][1] - - return "".join(new_string) - - -def compute_relative_tolerance_from_significant_decimals(string): - rtol = None - string = string.strip() - 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**(-max(len(significant_characters), DEFAULT_SIGNIFICANT_FIGURES)) - return rtol - - -# -------- (Sympy) Expression Parsing Utilities -class SymbolData(TypedDict): - latex: str - aliases: List[str] - - -SymbolDict = Dict[str, SymbolData] - -symbol_latex_re = re.compile( - r"(?P\\\(|\$\$|\$)(?P.*?)(?P\\\)|\$\$|\$)" -) - - -def sympy_symbols(symbols): - """Create a mapping of local variables for parsing sympy expressions. - - Args: - symbols (SymbolDict): A dictionary of sympy symbol strings to LaTeX - symbol strings. - - Note: - Only the sympy string is used in this function. - - Returns: - Dict[str, Symbol]: A dictionary of sympy symbol strings to sympy - Symbol objects. - """ - return {k: Symbol(k) for k in symbols} - - -def extract_latex(symbol): - """Returns the latex portion of a symbol string. - - Note: - Only the first matched expression is returned. - - Args: - symbol (str): The string to extract latex from. - - Returns: - str: The latex string. - """ - if (match := symbol_latex_re.search(symbol)) is None: - return symbol - - return match.group("latex") - - -def latex_symbols(symbols): - """Create a mapping between custom Symbol objects and LaTeX symbol strings. - Used when parsing a sympy Expression to a LaTeX string. - - Args: - symbols (SymbolDict): A dictionary of sympy symbol strings to LaTeX - symbol strings. - - Returns: - Dict[Symbol, str]: A dictionary of sympy Symbol objects to LaTeX - strings. - """ - symbol_dict = { - Symbol(k): extract_latex(v["latex"]) - for (k, v) in symbols.items() - } - return symbol_dict - - -def sympy_to_latex(equation, symbols, settings=None): - default_settings = { - "symbol_names": latex_symbols(symbols), - "ln_notation": True, - } - if settings is None: - settings = default_settings - else: - for key in default_settings.keys(): - if key not in settings.keys(): - settings[key] = default_settings[key] - latex_out = ModifiedLatexPrinter(settings).doprint(equation) - return latex_out - - -def create_sympy_parsing_params(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): - ''' - Input: - params : evaluation function parameter dictionary - unsplittable_symbols : list of strings that will not be split when parsing - even if implicit multiplication is used. - Output: - parsing_params: A dictionary that contains necessary info for the - parse_expression function. - ''' - - unsplittable_symbols = list(unsplittable_symbols)+params.get("reserved_keywords", []) - if "symbols" in params.keys(): - for symbol in params["symbols"].keys(): - if len(symbol) > 1: - unsplittable_symbols.append(symbol) - - if params.get("specialFunctions", False) is True: - from sympy import beta, gamma, zeta, Lambda, Chi - else: - beta = Symbol("beta") - gamma = Symbol("gamma") - zeta = Symbol("zeta") - Lambda = Symbol("Lambda") - Chi = Symbol("Chi") - if params["complexNumbers"] is True: - from sympy import I - else: - I = Symbol("I") - if params["elementary_functions"] is True: - from sympy import E - else: - E = Symbol("E") - N = Symbol("N") - O = Symbol("O") - Q = Symbol("Q") - S = Symbol("S") - - symbol_dict = { - "beta": beta, - "gamma": gamma, - "zeta": zeta, - "Lambda": Lambda, - "Chi": Chi, - "I": I, - "N": N, - "O": O, - "Q": Q, - "S": S, - "E": E - } - - symbol_dict.update(sympy_symbols(unsplittable_symbols)) - - strict_syntax = params.get("strict_syntax", False) - - parsing_params = { - "unsplittable_symbols": unsplittable_symbols, - "strict_syntax": strict_syntax, - "symbol_dict": symbol_dict, - "extra_transformations": tuple(), - "elementary_functions": params["elementary_functions"], - "convention": params["convention"], - "simplify": params.get("simplify", False), - "rationalise": params.get("rationalise", True), - "constants": set(), - "complexNumbers": params["complexNumbers"], - "reserved_keywords": params.get("reserved_keywords",[]), - } - - symbol_assumptions = list(symbol_assumptions) - if "symbol_assumptions" in params.keys(): - symbol_assumptions_strings = params["symbol_assumptions"] - index = symbol_assumptions_strings.find("(") - while index > -1: - index_match = find_matching_parenthesis(symbol_assumptions_strings, index) - try: - symbol_assumption = eval(symbol_assumptions_strings[index+1:index_match]) - symbol_assumptions.append(symbol_assumption) - except (SyntaxError, TypeError) as e: - raise Exception("List of symbol assumptions not written correctly.") from e - index = symbol_assumptions_strings.find('(', index_match+1) - for symbol, assumption in symbol_assumptions: - try: - if assumption.lower() == "constant": - parsing_params["constants"] = parsing_params["constants"].union({symbol}) - if assumption.lower() == "function": - parsing_params["symbol_dict"].update({symbol: eval("Function('"+symbol+"')")}) - else: - parsing_params["symbol_dict"].update({symbol: eval("Symbol('"+symbol+"',"+assumption+"=True)")}) - except Exception as e: - raise Exception(f"Assumption {assumption} for symbol {symbol} caused a problem.") from e - - - return 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] - bracket_feedback = None - if not parameters.get("strict_syntax", False): - expr, bracket_feedback = convert_bracket_notation(expr) - expr, abs_feedback = convert_absolute_notation(expr, name) - feedback = bracket_feedback or abs_feedback - success = feedback is None - return success, expr, feedback - -def parse_expression(expr_string, parsing_params): - ''' - Input: - expr_string : string to be parsed into a sympy expression - parsing_params : dictionary that contains parsing parameters - Output: - sympy expression created by parsing expr configured according - to the parameters in 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] - substitutions = separate_unsplittable_symbols - - parsed_expr_set = set() - - for expr in expr_set: - if not strict_syntax: - expr, _ = convert_bracket_notation(expr) - expr = preprocess_according_to_chosen_convention(expr, parsing_params) - - substitutions = list(set(substitutions)) - substitutions += transform_unicode_greek_symbols(expr) - substitutions.sort(key=substitutions_sort_key) - - 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, 10] + extra_transformations - else: - transformations = (parser_transformations[0:5, 6, 10] + 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: - return parsed_expr_set diff --git a/app/utility/physical_quantity_utilities.py b/app/utility/physical_quantity_utilities.py deleted file mode 100644 index 1b73df9..0000000 --- a/app/utility/physical_quantity_utilities.py +++ /dev/null @@ -1,602 +0,0 @@ -import re -from enum import Enum -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 ..preview_implementations.symbolic_preview import preview_function as symbolic_preview -from .preview_utilities import parse_latex - -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 = 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 is None: - self.messages = [] - else: - self.messages = messages - self.ast_root = ast_root - self.parser = parser - self.tag_handler = tag_handler - self.value = None - self.unit = None - self._rotate_until_root_is_split() - if self.ast_root.label == "SPACE"\ - and QuantityTags.U not in self.ast_root.children[0].tags\ - and QuantityTags.U in self.ast_root.children[1].tags: - self.value = self.ast_root.children[0] - self.unit = self.ast_root.children[1] - elif QuantityTags.U in self.ast_root.tags: - self.unit = self.ast_root - else: - self.value = self.ast_root - if self.value is not None: - 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 += [ - ( - 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) - self.unit_latex_string = None - if self.unit is not None: - self.unit_latex_string = "".join(self._unit_latex(self.unit)) - separator = "" - if self.value_latex_string is not None and self.unit_latex_string is not None: - separator = "~" - value_latex = self.value_latex_string if self.value_latex_string is not None else "" - unit_latex = self.unit_latex_string if self.unit_latex_string is not None else "" - self.latex_string = value_latex+separator+unit_latex - self.standard_value, self.standard_unit, self.expanded_unit, self.dimension, self.converted_unit_factor = self._all_forms() - return - - def _rotate(self, direction): - # right: direction = 1 - # left: direction = 0 - if direction not in {0, 1}: - raise Exception("Unknown direction: "+str(direction)) - old_root = self.ast_root - new_root = old_root.children[1-direction] - if len(new_root.children) == 1: - old_root.children = old_root.children[1-direction:len(old_root.children)-direction] - a = [] if direction == 0 else [old_root] - b = [old_root] if direction == 0 else [] - new_root.children = a+new_root.children+b - elif len(new_root.children) > 1: - switch = new_root.children[-direction] - old_root.children[1-direction] = switch - new_root.children[-direction] = old_root - else: - direction_string = "right" if direction == 1 else "left" - raise Exception("Cannot rotate "+direction_string+".") - old_root.tags = self.tag_handler(old_root) - new_root.tags = self.tag_handler(new_root) - self.ast_root = new_root - return - - def _rotate_right(self): - if len(self.ast_root.children) > 0: - self._rotate(1) - else: - raise Exception("Cannot rotate right.") - return - - def _rotate_left(self): - if len(self.ast_root.children) > 0: - self._rotate(0) - else: - raise Exception("Cannot rotate left.") - return - - def _rotate_until_root_is_split(self): - if self.ast_root.label == "SPACE": - if QuantityTags.U not in self.ast_root.tags and len(self.ast_root.children[1].children) > 0: - self._rotate_left() - self._rotate_until_root_is_split() - elif QuantityTags.U in self.ast_root.children[0].tags and len(self.ast_root.children[0].children) > 0: - self._rotate_right() - self._rotate_until_root_is_split() - return - - 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() - value = symbolic_preview(original_string, preview_parameters) - value_latex = value["preview"]["latex"] - return value_latex - return None - - def _unit_latex(self, node): - # TODO: skip unnecessary parenthesis (i.e. check for GROUP children for powers and fraction and inside groups) - content = node.content - children = node.children - if node.label == "PRODUCT": - return self._unit_latex(children[0])+["\\cdot"]+self._unit_latex(children[1]) - elif node.label == "NUMBER": - return [content] - elif node.label == "SPACE": - return self._unit_latex(children[0])+["~"]+self._unit_latex(children[1]) - elif node.label == "UNIT": - return ["\\mathrm{"]+[content]+["}"] - elif node.label == "GROUP": - out = [content[0]] - for child in children: - out += self._unit_latex(child) - return out+[content[1]] - elif node.label == "POWER": - return self._unit_latex(children[0])+["^{"]+self._unit_latex(children[1])+["}"] - elif node.label == "SOLIDUS": - return ["\\frac{"]+self._unit_latex(children[0])+["}{"]+self._unit_latex(children[1])+["}"] - else: - return [content] - - def _expand_units(self, node): - if node.label == "UNIT" and len(node.children) == 0 and node.content not in [x[0] for x in set_of_SI_base_unit_dimensions]: - expanded_unit_content = conversion_to_base_si_units[node.content] - node = self.parser.parse(self.parser.scan(expanded_unit_content))[0] - for k, child in enumerate(node.children): - node.children[k] = self._expand_units(child) - return node - - def _all_forms(self): - parsing_params = self.parsing_params - converted_value = self.value.content_string() if self.value is not None else None - - if converted_value is not None and self.parameters.get("is_latex", False): - symbols = self.parameters.get("symbols", {}) - converted_value = parse_latex(converted_value, symbols, self.parameters.get("simplify", False)) - converted_unit = None - expanded_unit = None - converted_dimension = parse_expression("1", parsing_params) - converted_unit_factor = parse_expression("1", parsing_params) - if self.unit is not None: - converted_unit = self.unit.copy() - expanded_unit = self._expand_units(converted_unit) - converted_unit_string = expanded_unit.content_string() - try: - 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 - base_unit_dimensions = [(base_unit[0], base_unit[2]) for base_unit in set_of_SI_base_unit_dimensions] - if self.value is not None: - substitution_dict = {symbol: 1 for symbol in converted_unit.free_symbols if str(symbol) in [x[0] for x in set_of_SI_base_unit_dimensions]} - 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_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, converted_unit_factor - - -def SLR_generate_unit_dictionaries(units_string, strictness): - - if strictness == "legacy": - strictness = "natural" - - units_tuples = set() - for key in units_sets_dictionary.keys(): - if key in units_string: - units_tuples |= units_sets_dictionary[key] - - units_end = dict() - if strictness == "strict": - units = {x[0]: x[0] for x in units_tuples} - units_short_to_long = {x[1]: x[0] for x in units_tuples} - units_long_to_short = {x[0]: x[1] for x in units_tuples} - elif strictness == "natural": - units = {x[0]: x[0] for x in units_tuples} - for unit in units_tuples: - units.update({x: unit[0] for x in unit[3]}) - units_end.update({x: unit[0] for x in unit[4]}) - units_short_to_long = {x[1]: x[0] for x in units_tuples} - units_long_to_short = {x[0]: x[1] for x in units_tuples} - for unit in units_tuples: - units_long_to_short.update({x: unit[1] for x in unit[3]}) - units_long_to_short.update({x: unit[1] for x in unit[4]}) - - prefixes = {x[0]: x[0] for x in set_of_SI_prefixes} - prefixes_long_to_short = {x[0]: x[1] for x in set_of_SI_prefixes} - prefixed_units = {**units} - for unit in units.keys(): - for prefix in prefixes.keys(): - prefixed_units.update({prefix+unit: prefix+units[unit]}) - # If prefixed short form overlaps with short form for other unit, do not include prefixed form - if prefixes_long_to_short[prefix]+units_long_to_short[unit] not in units_short_to_long.keys(): - prefixed_units.update( - { - prefixes_long_to_short[prefix]+units_long_to_short[unit]: prefix+units[unit] - } - ) - if prefix + units_long_to_short[unit] not in units_short_to_long.keys(): - prefixed_units.update( - { - prefix + units_long_to_short[unit]: prefix + units[unit] - } - ) - - - prefixed_units_end = {**units_end} - for unit in units_end.keys(): - for prefix in prefixes.keys(): - prefixed_units_end.update( - { - prefix+unit: prefix+units_end[unit], - } - ) - - return {**units, **units_short_to_long}, prefixed_units, units_end, prefixed_units_end - - -def set_tags(strictness): - def tag_handler(node): - tags = set() - for child in node.children: - tags = tags.union(child.tags) - if node.label == "UNIT": - tags.add(QuantityTags.U) - elif node.label == "NUMBER": - tags.add(QuantityTags.N) - elif node.label == "NON-UNIT": - tags.add(QuantityTags.V) - elif node.label == "POWER" and QuantityTags.U in node.children[0].tags and node.children[1].tags == {QuantityTags.N}: - tags.remove(QuantityTags.N) - 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 - 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: - tags.remove(QuantityTags.U) - if QuantityTags.N in tags: - tags.add(QuantityTags.V) - elif node.label in "SPACE" and QuantityTags.V in node.children[1].tags: - if QuantityTags.U in tags: - tags.remove(QuantityTags.U) - elif node.label == "GROUP" and len(node.content[0]+node.content[1]) == 0: - if strictness == "strict": - for (k, child) in enumerate(node.children): - node.children[k] = tag_removal(child, QuantityTags.U) - if QuantityTags.U in tags: - tags.remove(QuantityTags.U) - tags.add(QuantityTags.R) - elif strictness == "natural": - for child in node.children: - if QuantityTags.V in child.tags and QuantityTags.U in tags: - tags.remove(QuantityTags.U) - break - return tags - return tag_handler - - -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()]) - - if strictness == "strict": - units_dictionary.update(prefixed_units_dictionary) - - def starts_with_unit(string): - token = None - unit = None - for k in range(max_unit_name_length, -1, -1): - unit = units_dictionary.get(string[0:k+1], None) - if unit is not None: - token = string[0:k+1] - break - return token, unit - elif strictness == "natural": - chars_in_keys = set() - for key in { - **units_dictionary, - **prefixed_units_dictionary, - **prefixed_units_end_dictionary, - **units_end_dictionary - }.keys(): - for c in key: - chars_in_keys.add(c) - - def starts_with_unit(string): - units_end = {**prefixed_units_end_dictionary, **units_end_dictionary} - units = {**prefixed_units_dictionary, **units_dictionary} - token = None - unit = None - end_point = len(string) - for k, c in enumerate(string): - if c not in chars_in_keys: - end_point = k - break - if end_point > 0: - local_string = string[0:end_point] - # Check if string is end unit alternative - unit = units_end.get(local_string, None) - if unit is not None: - token = local_string - else: - # Check if string starts with unit - for k in range(len(local_string), -1, -1): - unit = units.get(local_string[0:k], None) - if unit is not None: - token = local_string[0:k] - break - return token, unit - - def starts_with_number(string): - match_content = re.match('^-?(0|[1-9]\d*)?(\.\d+)?(?<=\d)(e-?(0|[1-9]\d*))?', string) - match_content = match_content.group() if match_content is not None else None - return match_content, match_content - - start_symbol = "START" - end_symbol = "END" - null_symbol = "NULL" - - token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" +", "SPACE"), - (" *\* *", "PRODUCT"), - (" */ *", "SOLIDUS"), - (" *\^ *", "POWER"), - (" *\*\* *", "POWER"), - ("\( *", "START_DELIMITER"), - (" *\)", "END_DELIMITER"), - ("N", "NUMBER", starts_with_number), - ("U", "UNIT", starts_with_unit), - ("V", "NON-UNIT", catch_undefined), - ("Q", "QUANTITY_NODE", None), - ] - - if strictness == "strict": - juxtaposition = group(2, empty=True) - elif strictness == "natural": - def juxtaposition_natural(production, output, tag_handler): - is_unit = [False, False] - for k, elem in enumerate(output[-2:], -2): - if isinstance(elem, ExprNode): - is_unit[k] = QuantityTags.U in elem.tags and elem.label != "GROUP" - is_unit[k] = is_unit[k] or (elem.tags == {QuantityTags.U} and elem.label == "GROUP") - else: - is_unit[k] = elem.label == "UNIT" - if all(is_unit): - return insert_infix(" ", "SPACE")(production, output, tag_handler) - else: - for k, elem in enumerate(output[-2:], -2): - if is_unit[k] is True: - elem.tags.add(QuantityTags.V) - return group(2, empty=True)(production, output, tag_handler) - juxtaposition = juxtaposition_natural - - productions = [(start_symbol, "Q", relabel)] - productions += [("Q", "Q"+x+"Q", infix) for x in list(" */")] - productions += [("Q", "QQ", juxtaposition)] - productions += [("Q", "Q^Q", infix)] - productions += [("Q", "(Q)", group(1))] - productions += [("Q", "U", create_node)] - productions += [("Q", "N", create_node)] - productions += [("Q", "V", create_node)] - - def error_action_null(p, s, a, i, t, o): - raise Exception("Parser reached impossible state, no 'NULL' token should exists in token list.") - - def error_action_start(p, s, a, i, t, o): - raise Exception("Parser reached impossible state, 'START' should only be found once in token list.") - - def error_condition_incomplete_expression(items_token, next_symbol): - if next_symbol.label == "END": - return True - else: - return False - - def error_action_incomplete_expression(p, s, a, i, t, o): - raise Exception("Input ended before expression was completed.") - - def error_condition_infix_missing_argument(items_token, next_symbol): - if next_symbol.label in ["PRODUCT", "SOLIDUS", "POWER"]: - return True - else: - return False - - def error_action_infix_missing_argument(p, s, a, i, t, o): - raise Exception("Infix operator requires an argument on either side.") - - error_handler = [ - (lambda items_token, next_symbol: next_symbol.label == "NULL", error_action_null), - (lambda items_token, next_symbol: next_symbol.label == "START", error_action_start), - (error_condition_incomplete_expression, error_action_incomplete_expression), - (error_condition_infix_missing_argument, error_action_infix_missing_argument), - ] - - parser = SLR_Parser(token_list, productions, start_symbol, end_symbol, null_symbol, tag_handler=set_tags(strictness), error_handler=error_handler) - return parser - - -def SLR_quantity_parsing(expr, parameters, parser, name): - expr = expr.strip() - tokens = parser.scan(expr) - - quantity = parser.parse(tokens, verbose=False) - - if len(quantity) > 1: - raise Exception("Parsed quantity does not have a single root.") - - tag_handler = set_tags(parameters.get("strictness", "strict")) - return PhysicalQuantity(name, parameters, quantity[0], parser, messages=[], tag_handler=tag_handler) - -def expression_preprocess(name, expr, parameters): - if parameters.get("strictness", "natural") == "legacy": - expr = preprocess_legacy(expr, parameters) - return True, expr, None - - expr = transform_prefixes_to_standard(expr) - - return True, expr, None - -def preprocess_legacy(expr, parameters): - 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) - - return expr - -def transform_prefixes_to_standard(expr): - """ - Transform ONLY alternative prefix spellings to standard prefix names. - Ensure there's exactly one space after the prefix before the unit. - Works for both attached (e.g. 'km') and spaced (e.g. 'k m') forms. - """ - - for prefix_name, symbol, power, alternatives in set_of_SI_prefixes: - for alt in alternatives: - if not alt: - continue - - # Match the alternative prefix either attached to or followed by spaces before a unit - # Examples matched: "km", "k m", "microsecond", "micro second" - pattern = rf'(? tuple: - """ - Replace all symbols starting with 'E' or 'e' with placeholders, - so latex2sympy does not interpret 'E' as Euler's number. - Returns the modified string and a dict mapping replaced chars to their placeholders. - """ - replacements = {} - - # Find placeholder for uppercase E if needed - if re.search(r'(? original char - placeholder_to_char = {v: k for k, v in replacements.items()} - - subs = {} - for s in expr.free_symbols: - name = str(s) - # Check if this symbol starts with any of our placeholders - for placeholder, original_char in placeholder_to_char.items(): - if name.startswith(placeholder): - new_name = original_char + name[len(placeholder):] - subs[s] = Symbol(new_name) - break # Only replace once per symbol - - return expr.xreplace(subs) - - - -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_str_postprocess = latex2sympy(latex_symbol_str) - except Exception: - try: - latex_symbol_str_preprocessed, replacements = preprocess_E(latex_symbol_str) - latex_symbol_parsed = latex2sympy(latex_symbol_str_preprocessed) - latex_symbol_str_postprocess = postprocess_E(latex_symbol_parsed, replacements) - - except Exception: - raise ValueError( - f"Couldn't parse latex symbol {latex_symbol_str} " - f"to sympy symbol." - ) - substitutions[latex_symbol_str_postprocess] = Symbol(sympy_symbol_str) - - - aliases = symbols[sympy_symbol_str]['aliases'] - transformations = (standard_transformations + (implicit_multiplication_application,)) - for alias in aliases: - if not alias.strip(): - continue - try: - parsed_alias = parse_expr( - alias, - transformations=transformations, - global_dict={}, - local_dict={'Symbol': Symbol,'E': Symbol("E")} - ) - substitutions[parsed_alias] = Symbol(sympy_symbol_str) - except Exception as e: - print(e) - substitutions[Symbol(alias)] = Symbol(sympy_symbol_str) - - - parsed_responses = set() - for expression in response_set: - try: - expression_postprocess = latex2sympy(expression, substitutions) - except Exception: - try: - expression_preprocessed, replacements = preprocess_E(expression) - expression_parsed = latex2sympy(expression_preprocessed, substitutions) - if isinstance(expression_parsed, list): - expression_parsed = expression_parsed.pop() - - expression_postprocess = postprocess_E(expression_parsed, replacements) - except Exception as e: - raise ValueError("Failed to pass expression during preview: ", str(e)) - - if simplify is True: - expression_postprocess = expression_postprocess.simplify() - - parsed_responses.add(str(expression_postprocess.subs(substitutions))) - - if len(parsed_responses) < 2: - return parsed_responses.pop() - else: - return '{'+', '.join(sorted(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/app/utility/slr_parsing_utilities.py b/app/utility/slr_parsing_utilities.py deleted file mode 100644 index 5841f66..0000000 --- a/app/utility/slr_parsing_utilities.py +++ /dev/null @@ -1,845 +0,0 @@ -# ----------------- -# ----------------- -# UTILITY FUNCTIONS -# ----------------- -# ----------------- - -# ----------------- -# Scanner utilities -# ----------------- -import re - - -def catch_undefined(label, content, original, start, end): - return Token(label, content, original, start, end) - -# ----------------- -# Parser utilities -# ----------------- - - -# Syntax tree building utilities -def proceed(production, output, tag_handler): - return output - - -def package(production, output, tag_handler): - label = production[0].label - handle = production[1] - children = output[-len(handle):] - output = output[0:(-len(handle))] - package_content = "".join(str(children)) - new_package = ExprNode(Token(label, package_content, children[0].original, children[0].start, children[0].end), children, tag_handler=tag_handler) - output.append(new_package) - return output - - -def append(production, output, tag_handler): - handle = production[1] - children = output[1-len(handle):] - output = output[0:(1-len(handle))] - output[-1].children += children - return output - - -def append_last(production, output, tag_handler): - handle = production[1] - last = output[-1] - output = output[0:(1-len(handle))] - output[-1].children.append(last) - return output - - -def join(production, output, tag_handler): - label = production[0].label - handle = production[1] - content = [] - for node in output[-len(handle):]: - try: - content.append(node.content_string()) - except Exception: - content.append(node.content) - joined_content = "".join(content) - joined_end = output[-1].end - output = output[0:(1-len(handle))] - output[-1].label = label - output[-1].content = joined_content - output[-1].end = joined_end - return output - - -def create_node(production, output, tag_handler): - a = output.pop() - node = ExprNode(a, [], tag_handler=tag_handler) - output.append(node) - return output - - -def relabel(production, output, tag_handler): - a = output.pop() - output.append(Token(production[0].label, a.content, a.original, a.start, a.end)) - return output - - -def group(number_of_elements, empty=False, delimiters=["", ""]): - if number_of_elements < 1: - raise Exception("Groups must have at least one element.") - - def wrap(production, output, tag_handler): - if empty: - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - end_delim = Token("START_DELIMITER", delimiters[1], content[0].original, content[0].start, content[0].end) - start_delim = Token("END_DELIMITER", delimiters[0], content[0].original, content[0].start, content[0].end) - else: - end_delim = output.pop() - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - start_delim = 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) - output.append( - ExprNode( - Token( - "GROUP", - [start_delim.content, end_delim.content], - content[0].original, - start_delim.start, end_delim.end - ), - content, traverse_step=traverse_group, tag_handler=tag_handler) - ) - return output - - return wrap - - -def operate(number_of_elements, empty=False): - if number_of_elements < 0: - raise Exception("Operations must have at least one argument.") - - def wrap(production, output, tag_handler): - if empty: - end_index = output[-1].end - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - else: - end_delim = output.pop() - end_index = end_delim.end - content = output[-number_of_elements:] - output = output[0:-number_of_elements] - _ = 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) - operation = output.pop() - node = ExprNode(operation, content, tag_handler=tag_handler) - node.end = end_index - output.append(node) - return output - - return wrap - - -def infix(production, output, tag_handler): - right = output.pop() - operator = output.pop() - left = output.pop() - output.append(ExprNode(operator, [left, right], traverse_step=traverse_infix, tag_handler=tag_handler)) - return output - - -def insert_infix(content, label): - def apply(production, output, tag_handler): - operator_token = Token(label, content, output[-1].original, len(output[-1].original), -1) - return infix(production, output[0:-1]+[operator_token]+[output[-1]], tag_handler=tag_handler) - return apply - - -def compose(*funcs): - def composed_functions(production, output, tag_handler): - for f in reversed(funcs): - output = f(production, output, tag_handler) - return output - return composed_functions - - -def flatten(production, output, tag_handler): - node = output[-1] - flattened_children = [] - for child in node.children: - if node.label == child.label and node.content == child.content: - flattened_children += child.children - else: - flattened_children.append(child) - node.children = flattened_children - return output - - -# Tag management utilities - -def tag_rule_union(x, y): - return x | y - - -def tag_rule_intersection(x, y): - return x & y - - -def tag_transfer(node, rule=tag_rule_union): - tags = set() - if len(node.children) > 0: - tags = node.children[0].tags - for child in node.children[1:]: - tags = rule(tags, child.tags) - return tags - - -def tag_removal(node, tag, rule=lambda x: True): - if rule(node.tags) and tag in node.tags: - node.tags.remove(tag) - return node - - -def tag(node, tag=None, rule=lambda x: True): - if tag is not None: - if node.tags is None: - node.tags = set() - if rule(node.tags): - node.tags.add(tag) - return node - - -def tag_replace(node, old_tag, new_tag): - if old_tag in node.tags: - node = tag(node, new_tag) - node = tag_removal(node, old_tag) - return node - -# ------------------------ -# Node traversal utilities -# ------------------------ - - -def traverse_prefix(expr_node, action): - out = [(True, action(expr_node))] - for x in expr_node.children: - out += [(False, x)] - return out - - -def traverse_postfix(expr_node, action): - out = [] - for x in expr_node.children: - out += [(False, x)] - return out+[(True, action(expr_node))] - - -def traverse_infix(expr_node, action): - out = [] - for x in expr_node.children[0:-1]: - out += [(False, x), (True, action(expr_node))] - return out+[(False, expr_node.children[-1])] - - -def traverse_group(expr_node, action): - out = [(True, action(expr_node)[0])] - for x in expr_node.children: - out += [(False, x)] - return out+[(True, action(expr_node)[1])] - - -# error handling utilities -def new_root_on_error(parser, stack, a, input_tokens, tokens, output): - a = parser.end_token - return stack, a, input_tokens, tokens, output - - -def discard_output_until_on_error(condition): - def error(parser, stack, a, input_tokens, tokens, output): - while not condition(output[-1]): - output.pop() - a = parser.end_token - return stack, a, input_tokens, tokens, output - return error - -# -------------------------- -# Parser generator utilities -# -------------------------- - - -def SLR_expression_parser(nodes=[], infix_operators=[], delimiters=[], undefined=None, costum_tokens=[], costum_productions=[], group_node=None, expression_node=None, start=None, null=None, end=None, error_handler=[]): - infix_operators_dictionary = dict() - unique_infix_operator_symbols = [] - for (symbol, label) in infix_operators: - if label in infix_operators_dictionary.keys(): - infix_operators_dictionary[label] += [symbol] - else: - unique_infix_operator_symbols += [symbol] - infix_operators_dictionary.update({label: [symbol]}) - infix_operators_token = [] - for (label, symbols) in infix_operators_dictionary.items(): - infix_operators_token.append(((" *("+"|".join([re.escape(s) for s in symbols])+") *"), label)) - - if undefined is None: - undefined_symbol = "UNDEFINED" - undefined = (undefined_symbol, undefined_symbol, catch_undefined) - else: - undefined += (catch_undefined,) - - if expression_node is None: - expression_node_symbol = "EXPRESSION_NODE" - expression_node = (expression_node_symbol, expression_node_symbol, None) - else: - undefined += (None,) - - if group_node is None: - group_node_symbol = "GROUP_NODE" - group_node = (group_node_symbol, group_node_symbol, None) - - if start is None: - start_symbol = "START" - start = (start_symbol, start_symbol) - - if end is None: - end_symbol = "END" - end = (end_symbol, end_symbol) - - if null is None: - null_symbol = "NULL" - null = (null_symbol, null_symbol) - - token_list = [undefined, null, expression_node, start, end]+nodes+infix_operators_token+costum_tokens - - productions = [(start[0], expression_node[0], relabel)] - productions += [(expression_node[0], n[0], create_node) for n in nodes] - productions += [(expression_node[0], undefined[0], create_node)] - productions += [(expression_node[0], expression_node[0]+operator+expression_node[0], infix) for operator in [op[0] for op in unique_infix_operator_symbols]] - - for (delims, action) in delimiters: - token_list += [(re.escape(delims[0])+" *", "START_DELIMITER"), (" *"+re.escape(delims[1]), "END_DELIMITER")] - productions += [(expression_node[0], delims[0]+expression_node[0]+delims[1], action)] - - productions += costum_productions - - return SLR_Parser(token_list, productions, start[1], end[1], null[1], error_handler=error_handler) - -# ------- -# ------- -# CLASSES -# ------- -# ------- - - -class Token: - - def __init__(self, label, content, original, start, end): - self.label = label - self.content = content - self.original = original - self.start = start - self.end = end - return - - def __eq__(self, other): - return isinstance(other, Token) and self.label == other.label - - def __hash__(self): - return hash(self.label) - - def __str__(self): - return str(self.label)+": "+str(self.content) - - def __repr__(self): - # REMARK(KarlLundengaard): This is not a good repr function, but it means that the most - # relevant info is printed in the watch window of my preferred debugger - return str(self.label)+": "+str(self.content) - - -class ExprNode(Token): - - def __init__(self, token, children, tag_handler=None, tags=set(), traverse_step=traverse_prefix): - super().__init__(token.label, token.content, token.original, token.start, token.end) - self.tags = set() - self.children = [] - for child in children: - if isinstance(child, ExprNode): - self.children.append(child) - elif isinstance(child, Token): - self.children.append(ExprNode(child, [])) - else: - raise Exception(f"Invalid child {str(child)}") - self._traverse_step = traverse_step - if tag_handler is not None: - self.tags = tag_handler(self) - else: - self.tags = tags - return - - def copy(self): - token = Token(self.label, self.content, self.original, self.start, self.end) - children = [] - for child in self.children: - children.append(child.copy()) - return ExprNode(token, children, tags=self.tags, traverse_step=self._traverse_step) - - def tree_string(self): - s = str(self) - for k, child in enumerate(self.children): - padding = "\n| " if k < len(self.children)-1 else "\n " - s += "\n"+str(k)+": "+child.tree_string().replace("\n", padding) - return s - - def content_string(self, max_depth=None): - output = self.traverse(lambda x: x.content, max_depth) - return "".join(output) - - def traverse(self, action, max_depth=None): - stack = [x+(0,) for x in self._traverse_step(self, action)[::-1]] - output = [] - while len(stack) > 0: - (is_output, elem, depth) = stack.pop() - if max_depth is None or depth <= max_depth: - if is_output: - output.append(elem) - else: - stack += [x+(depth+1,) for x in elem._traverse_step(elem, action)[::-1]] - return output - - def original_string(self): - left_children = self.children - right_children = self.children - start = self.start - end = self.end - while len(left_children) > 0: - start = min(start, left_children[0].start) - left_children = left_children[0].children - while len(right_children) > 0: - end = max(end, right_children[-1].end) - right_children = right_children[-1].children - return self.original[start:end+1] - - def __str__(self): - tags = " tags: " - tags += str(self.tags) if len(self.tags) > 1 else " {}" - return str(self.label)+": "+str(self.content)+tags - - def __repr__(self): - # REMARK(KarlLundengaard): This is not a good repr function, but it means that the most - # relevant info is printed in the watch window of my preferred debugger - return str(self.label)+": "+str(self.content)+" tags: "+str(self.tags) - - -class SLR_Parser: - - def default_error_action(parser, stack, a, input_tokens, tokens, output): - m = 70 - raise Exception( - f"\n{'-'*m}\n" + - f"ERROR:\n{'-'*m}\n" + - f"accepted: {input_tokens[:-len(tokens)]}\n" + - f"current: {a}, {parser._symbols_index[a]}\n" + - f"remaining: {tokens}\n" + - f"stack: {stack}\n" + - f"output: {output}\n" + - f"state: {parser.state_string(parser._states_index[stack[-1]])}\n" + - f"{'-'*m}") - - def __init__(self, token_list, productions, start_symbol, end_symbol, null_symbol, error_handler=[], tag_handler=tag_transfer): - self.token_list = token_list - self.token_list.sort(key=lambda x: -len(x[0])) - self.productions = productions - self.start_symbol = start_symbol - self.end_symbol = end_symbol - self.null_symbol = null_symbol - self.start_token = self.scan(start_symbol, mode="bnf")[0] - self.end_token = self.scan(end_symbol, mode="bnf")[0] - self.null_token = self.scan(null_symbol, mode="bnf")[0] - start_token = self.start_token - end_token = self.end_token - null_token = self.null_token - self.error_handler = error_handler - self.tag_handler = tag_handler - - # Check if there are any duplicate productions - checked_productions = [] - duplicate_error_string = [] - for prod in [(x[0], x[1]) for x in productions]: - if prod in checked_productions: - duplicate_error_string.append(f"duplicate: {prod}") - checked_productions.append(prod) - if len(duplicate_error_string) > 0: - raise Exception("There are duplicate productions:\n" + "\n".join(duplicate_error_string)) - - # Tokenize productions - productions_token = [ - (self.scan(x[0], mode="bnf")[0], self.scan(x[1], mode="bnf")) for x in productions - ] - self.productions_token = productions_token - - # Analyse productions to find terminals and non-terminals - non_terminals_token = [] - terminals_token = [self.end_token, self.null_token] - for token in [prod[0] for prod in productions_token]: - if token not in non_terminals_token: - non_terminals_token.append(token) - for production in productions_token: - for token in production[1]: - if token not in terminals_token and token not in non_terminals_token: - terminals_token.append(token) - self.symbols = terminals_token+non_terminals_token - self.terminals_token = terminals_token - self.non_terminals_token = non_terminals_token - - # Create reductions dictionary - self.reductions = {tuple(productions_token[k][1]): productions[k][2] for k in range(0, len(productions))} - - # Compute dictionary with FIRST for all single tokens - first_dict = {**{x: [x] for x in terminals_token}, **{x: [] for x in non_terminals_token}} - lengths = [-1]*len(non_terminals_token) - any_first_changed = True - while any_first_changed: - any_first_changed = False - for k, x in enumerate(non_terminals_token): - if lengths[k] != len(first_dict[x]): - lengths[k] = len(first_dict[x]) - any_first_changed = True - for nt in non_terminals_token: - prods = [x[1] for x in productions_token if x[0] == nt] - for prod in prods: - for token in prod: - for x in first_dict[token]: - if x not in first_dict[nt]: - first_dict[nt].append(x) - if null_token not in first_dict[token]: - break - self._first_dict = first_dict - - # Compute dictionary with FOLLOW for all non_terminals - first = self.first - follow = {x: [] for x in non_terminals_token} - follow[start_token].append(end_token) - lengths = [-1]*len(non_terminals_token) - while lengths != [len(follow[x]) for x in non_terminals_token]: - lengths = [len(follow[x]) for x in non_terminals_token] - for (head, body) in productions_token: - for k, token in enumerate(body): - if token in non_terminals_token: - if null_token in self.first(body[k+1:]) or len(body[k+1:]) == 0: - for item in follow[head]: - if item not in follow[token]: - follow[token].append(item) - for item in first(body[k+1:]): - if item != null_token and item not in follow[token]: - follow[token].append(item) - self._follow = follow - - # Compute all states and the transitions between them - closure = self.closure - compute_transitions = self.compute_transitions - start_productions = tuple([(k, 0) for k in range(0, len(productions_token)) if productions_token[k][0] == start_token]) - states = {start_productions: closure(start_productions)} - transitions = {} - new_states = [start_productions] - while len(new_states) > 0: - state = new_states.pop(0) - trans = compute_transitions(closure(list(state))) - transitions.update({state: trans}) - for t in trans: - if tuple(t[1]) not in states.keys(): - states.update({tuple(t[1]): closure(t[1])}) - new_states.append(tuple(t[1])) - self.states = states - self.transitions = transitions - - # Create index dictionaries to simplify state table construction - states_index = {} - for i, s in enumerate(states): - states_index.update({s: i, i: s}) - self._states_index = states_index - - symbols_index = {} - symbols = terminals_token+non_terminals_token - for j, h in enumerate(symbols): - symbols_index.update({h: j, j: h}) - self._symbols_index = symbols_index - - # Compute parsing table - parsing_table = [] - for i in range(0, len(states)): - parsing_table.append([]) - for j in range(0, len(symbols)): - parsing_table[i].append([]) - - for state in states: - for (production_index, dot_index) in closure(state): - # Fill in shift actions and goto table - (head, body) = productions_token[production_index] - if dot_index < len(body): - a = body[dot_index] - table_entry = parsing_table[states_index[state]][symbols_index[a]] - for (symbol, next_state) in transitions[state]: - if symbol == a: - table_entry.append((production_index, states_index[tuple(next_state)])) - break - elif head == self.start_token: - parsing_table[states_index[state]][symbols_index[self.end_token]].append((production_index, len(self.states))) - else: - # Fill in reduce actions - for a in follow[head]: - table_entry = parsing_table[states_index[state]][symbols_index[a]] - table_entry.append((production_index, len(states)+production_index)) - - # Choose correct table entry based on precedence, - # precedence is determined by location in productions - # array (higher index in array means higher precedence) - for i in range(0, len(states)): - for j in range(0, len(symbols)): - table_entry = parsing_table[i][j] - if len(table_entry) == 0: - # items_token gives a list of pairs that contain the parts of the production before and after the current point - items_token = [(productions_token[x[0]][1][0:x[1]], productions_token[x[0]][1][x[1]:]) for x in states_index[i]] - next_symbol = symbols_index[j] - parsing_table[i][j] = -1 - for (index, condition) in enumerate([x[0] for x in self.error_handler], 2): - if condition(items_token, next_symbol): - parsing_table[i][j] = -index - break - elif len(table_entry) == 1: - parsing_table[i][j] = table_entry[0][1] - else: - precedence = table_entry[0][0] - index = 0 - for k, action in enumerate(table_entry[1:], 1): - if precedence < action[0]: - precedence = action[0] - index = k - parsing_table[i][j] = table_entry[index][1] - self.parsing_table = parsing_table - - # Check for unreachable states and reductions - unreachable = list(range(0, len(states)+len(self.reductions))) - for i in range(0, len(states)): - for j in range(0, len(symbols)): - if parsing_table[i][j] in unreachable: - unreachable.remove(parsing_table[i][j]) - self.parsing_table = parsing_table - - if len(unreachable) > 1: - print("Unreachable states:") - for x in [y for y in unreachable if y < len(states)]: - print("\t"+self.state_string(self._states_index[x])) - print("Unreachable reductions:") - for x in [y for y in unreachable if y >= len(states) and y < len(states)+len(productions_token)]: - print("\t"+self.productions[x-len(states)][0]+"-->"+str(self.productions[x-len(states)][1])) - - return - - def scan(self, expr, mode="expression"): - token_list = self.token_list - tokens = [] - - 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: - raise Exception("Only one token type can be used to catch undefined lexemes.") - elif len(token_catch_undefined) < 1: - token_catch_undefined = None - else: - token_catch_undefined = token_catch_undefined[0] - - if "expression" in mode: - token_rules = [x for x in token_list if len(x) > 2 and x[2] not in {None, catch_undefined}] - token_symbols = [x for x in token_list if len(x) == 2] - elif "bnf" in mode: - token_rules = [] - token_symbols = [(x[0], x[1]) for x in token_list] - - index = 0 - string = "" - while index-len(string) < len(expr): - end_token = None - end_token_length = 0 - content = "" - label = None - for (re_content, current_label) in token_symbols: - match_content = re.match(re_content, expr[index:]) - if match_content is not None: - current_content = match_content.group() - if len(current_content) > end_token_length: - content = current_content - end_token_length = len(content) - label = current_label - for token in token_rules: - current_label = token[1] - match_rule, match_content = token[2](expr[index:]) - if match_rule is not None: - if len(match_rule) > end_token_length: - content = match_content - end_token_length = len(match_rule) - label = current_label - if label is None: - string = string+expr[index] - index += 1 - else: - end_token = new_token(label, content, index, index+end_token_length-1) - if len(string) > 0 and (end_token is not None or index >= len(expr)): - if token_catch_undefined is not None: - tokens.append(token_catch_undefined[2](token_catch_undefined[1], string, expr, index-len(string), index-1)) - else: - raise Exception(f"Undefined input: {string}") - string = "" - if end_token is not None: - tokens.append(end_token) - index += end_token_length - return tokens - - def closure(self, item_set): - non_terminals = self.non_terminals_token - productions = self.productions_token - # Items are represented as (i,j) where i indicates index - # in production list and j position of inserted dot - closure_set = [] - offset = -1 - new_items = item_set - added_to_closure = [False]*len(non_terminals) - while len(new_items) > 0: - closure_set += new_items - offset += len(new_items) - new_items = [] - for item in closure_set: - i = item[0] - j = item[1] - follow = None - if j < len(productions[i][1]): - for k, nt in enumerate(non_terminals): - if productions[i][1][j] == nt: - follow = k - break - if follow is not None and not added_to_closure[follow]: - for k, production in enumerate(productions): - if production[0] == non_terminals[follow]: - new_items.append((k, 0)) - added_to_closure[follow] = True - return closure_set - - def compute_transitions(self, item_set): - productions_token = self.productions_token - transitions = [] - for item in item_set: - i = item[0] - j = item[1] - if j < len(productions_token[i][1]): - token = productions_token[i][1][j] - if token not in [x[0] for x in transitions]: - transitions.append((token, [(i, j+1)])) - else: - transitions[[x[0] for x in transitions].index(token)][1].append((i, j+1)) - return transitions - - def first(self, tokens): - # Computes FIRST for strings of tokens - null_token = self.null_token - first_dict = self._first_dict - if len(tokens) == 1: - return first_dict[tokens[0]] - fs = [] - for token in tokens: - for item in first_dict[token]: - if item not in fs and item != null_token: - fs.append(item) - if token != null_token: - break - return fs - - def parsing_table_to_string(self): - parsing_table = self.parsing_table - symbols = [x.content for x in self.terminals_token+self.non_terminals_token] - states = self.states - parsing_table_string = ["\t"+"\t".join(symbols)+"\n"] - - for i in range(0, len(parsing_table)): - parsing_table_string += [str(i)+"\t"] - for j in range(0, len(parsing_table[i])): - if parsing_table[i][j] < 0: - parsing_table_string += ['e'+str(-parsing_table[i][j])] - elif parsing_table[i][j] < len(states): - parsing_table_string += ['s'+str(parsing_table[i][j])] - else: - parsing_table_string += ['r'+str(parsing_table[i][j]-len(states))] - parsing_table_string += ['\t'] - parsing_table_string += ['\n'] - - return "".join(parsing_table_string) - - def state_string(self, state): - items = self.closure(state) - prod_strings = [] - for k in range(0, len(items)): - production = self.productions_token[items[k][0]] - dot_index = items[k][1] - prod_string = "".join(x.content for x in production[1][0:dot_index])+"."+"".join(x.content for x in production[1][dot_index:]) - prod_strings.append(prod_string) - return "I"+str(self._states_index[state])+": ("+", ".join(prod_strings)+")" - - def state_string_list(self, state): - items = self.closure(state) - prod_strings = [] - for k in range(0, len(items)): - production = self.productions_token[items[k][0]] - dot_index = items[k][1] - prod_string = "".join(x.content for x in production[1][0:dot_index])+"."+"".join(x.content for x in production[1][dot_index:]) - prod_strings.append(prod_string) - return ["I"+str(self._states_index[state])]+prod_strings - - def parsing_action(self, s, a): - return self.parsing_table[s][self._symbols_index[a]] - - def parse(self, input_tokens, verbose=False): - productions_token = self.productions_token - tokens = list(input_tokens) - if tokens[-1] != self.end_token: - tokens += [self.end_token] - a = tokens.pop(0) - stack = [0] - output = [] - while True: - parse_action = self.parsing_action(stack[-1], a) - while parse_action < 0: - if parse_action == -1: - self.default_error_action(stack, a, input_tokens, tokens, output) - else: - stack, a, input_tokens, tokens, output = self.error_handler[-2-parse_action][1](self, stack, a, input_tokens, tokens, output) - parse_action = self.parsing_action(stack[-1], a) - if parse_action < len(self.states): - stack.append(parse_action) - output.append(ExprNode(a, [])) - if verbose: - print("shift and transition to: "+self.state_string(self._states_index[parse_action])+" \t"+str(output)) - a = tokens.pop(0) - elif parse_action == len(self.states): - if verbose: - print("ACCEPT") - if len(tokens) > 0 and tokens != [self.end_token]: - output += ExprNode(self.parse(tokens), []) - break - elif parse_action <= len(self.states)+len(self.productions_token): - production = productions_token[parse_action-len(self.states)] - reduction = self.reductions[tuple(production[1])] - output = reduction(production, output, self.tag_handler) - # print("-----------------------") - # print(output[0].tree_string()) - # print("-----------------------") - stack = stack[0:-len(production[1])] - stack.append(self.parsing_action(stack[-1], production[0])) - if verbose: - print("reduce by: "+str(production[0].content)+" --> "+"".join([x.content for x in production[1]])+" \t"+str(output)) - print("new state: "+self.state_string(self._states_index[stack[-1]])) - print("next input: "+str(a)) - else: - m = 70 - 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 diff --git a/app/utility/syntactical_comparison_utilities.py b/app/utility/syntactical_comparison_utilities.py deleted file mode 100644 index 89c5a76..0000000 --- a/app/utility/syntactical_comparison_utilities.py +++ /dev/null @@ -1,100 +0,0 @@ -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:]) - tail = escape_regex_reserved_characters(string[offset:]) - if len(tail) > 0: - tail = '('+tail+')' - tail = ''.join(tail.split()) - non_numbers.append(tail) - 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/app/utility/unit_system_conversions.py b/app/utility/unit_system_conversions.py deleted file mode 100644 index 884ddb5..0000000 --- a/app/utility/unit_system_conversions.py +++ /dev/null @@ -1,145 +0,0 @@ -# Remarks: -# only K, no other temperature scales included -# angles measures, bel an neper are all treated as identical dimensionless units - -""" -Prefixes taken from Table 5 https://physics.nist.gov/cuu/Units/prefixes.html and https://www.bipm.org/en/cgpm-2022/resolution-3 -""" -set_of_SI_prefixes = { - ('quetta', 'Q', '(10**30)', tuple()), - ('ronna', 'R', '(10**27)', tuple()), - ('yotta', 'Y', '(10**24)', tuple()), - ('zetta', 'Z', '(10**21)', tuple()), - ('exa', 'E', '(10**18)', tuple()), - ('peta', 'P', '(10**15)', tuple()), - ('tera', 'T', '(10**12)', tuple()), - ('giga', 'G', '(10**9)', tuple()), - ('mega', 'M', '(10**6)', tuple()), - ('kilo', 'k', '(10**3)', tuple()), - ('hecto', 'h', '(10**2)', tuple()), - ('deca', 'da', '(10**1)', ('deka',)), - ('deci', 'd', '(10**(-1))', tuple()), - ('centi', 'c', '(10**(-2))', tuple()), - ('milli', 'm', '(10**(-3))', tuple()), - ('micro', 'mu', '(10**(-6))', ("μ", "µ", "𝛍", "𝜇", "𝝁", "𝝻", "𝞵")), - ('nano', 'n', '(10**(-9))', tuple()), - ('pico', 'p', '(10**(-12))', tuple()), - ('femto', 'f', '(10**(-15))', tuple()), - ('atto', 'a', '(10**(-18))', tuple()), - ('zepto', 'z', '(10**(-21))', tuple()), - ('yocto', 'y', '(10**(-24))', tuple()), - ('ronto', 'r', '(10**(-27))', tuple()), - ('quecto', 'q', '(10**(-30))', tuple()) -} - -""" -SI base units taken from Table 1 https://physics.nist.gov/cuu/Units/units.html -Note that gram is used as a base unit instead of kilogram. -""" -set_of_SI_base_unit_dimensions = { - ('metre', 'm', 'length', ('meter',), ('metres', 'meters')), - ('gram', 'g', 'mass', tuple(), ('grams',)), - ('second', 's', 'time', tuple(), ('seconds',)), - ('ampere', 'A', 'electric_current', ('Ampere',), ('amperes', 'Amperes')), - ('kelvin', 'K', 'temperature', ('Kelvin',), ('kelvins', 'Kelvins')), - ('mole', 'mol', 'amount_of_substance', tuple(), ('moles',)), - ('candela', 'cd', 'luminous_intensity', ('Candela',), ('candelas', 'Candelas')), -} - -""" -Derived SI units taken from Table 3 https://physics.nist.gov/cuu/Units/units.html -Note that radians and degree have been moved to list_of_very_common_units_in_SI to reduce collisions when substituting. -Note that degrees Celsius is omitted. -""" -set_of_derived_SI_units_in_SI_base_units = { - ('hertz', 'Hz', '(second**(-1))', ('Hertz',), tuple()), - ('newton', 'N', '(metre*kilogram*second**(-2))', ('Newton',), ('newtons', 'Newtons')), - ('pascal', 'Pa', '(metre**(-1)*kilogram*second**(-2))', ('Pascal',), ('pascals', 'Pascals')), - ('joule', 'J', '(metre**2*kilogram*second**(-2))', ('Joule',), ('joules', 'Joules')), - ('watt', 'W', '(metre**2*kilogram*second**(-3))', ('Watt',), ('watts', 'Watts')), - ('coulomb', 'C', '(second*ampere)', ('Coulomb',), ('coulombs', 'Coulombs')), - ('volt', 'V', '(metre**2*kilogram*second**(-3)*ampere**(-1))', ('Volt',), ('volts', 'Volts')), - ('farad', 'F', '(metre**(-2)*(kilogram)**(-1)*second**4*ampere**2)', ('Farad',), ('farads', 'Farads')), - ('ohm', 'O', '(metre**2*kilogram*second**(-3)*ampere**(-2))', ('Ohm', "Ω", "𝛀", "𝛺", "𝜴", "𝝮", "𝞨"), ('ohms', 'Ohms')), - ('siemens', 'S', '(metre**(-2)*kilogram**(-1)*second**3*ampere**2)', ('Siemens',), tuple()), - ('weber', 'Wb', '(metre**2*kilogram*second**(-2)*ampere**(-1))', ('Weber',), ('webers', 'Webers')), - ('tesla', 'T', '(kilogram*second**(-2)*ampere**(-1))', ('Tesla',), ('teslas', 'Teslas')), - ('henry', 'H', '(metre**2*kilogram*second**(-2)*ampere**(-2))', ('Henry',), ('henrys', 'Henrys')), - ('lumen', 'lm', '(candela)', tuple(), ('lumens',)), - ('lux', 'lx', '(metre**(-2)*candela)', tuple(), tuple()), - ('becquerel', 'Bq', '(second**(-1))', ('Becquerel',), ('becquerels', 'Becquerels')), - ('gray', 'Gy', '(metre**2*second**(-2))', ('Gray',), ('grays', 'Grays')), - ('sievert', 'Sv', '(metre**2*second**(-2))', ('Sievert',), ('sieverts', 'Sieverts')), - ('katal', 'kat', '(second**(-1)*mole)', ('Katal',), ('katals', 'Katals')) -} - -""" -Commonly used non-SI units taken from Table 6 and 7 https://physics.nist.gov/cuu/Units/outside.html -Note that radian and steradian from Table 3 have been moved here to reduce collisions when substituting. -This is the subset of common symbols whose short form symbols are allowed -""" -set_of_very_common_units_in_SI = { - ('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', '(1/360)', ('°', ), ('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 - ('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')), -} - - -""" -Commonly used non-SI units taken from Table 6 and 7 https://physics.nist.gov/cuu/Units/outside.html -Note that short form symbols are defined here, but not used since they cause to many ambiguities -""" -set_of_common_units_in_SI = { - ('day', 'd', '(86400*second)', tuple(), ('days',)), - ('angleminute', "'", '(pi/10800)', tuple(), tuple()), - ('anglesecond', '"', '(pi/648000)', tuple(), tuple()), - ('astronomical_unit', 'au', '(149597870700*metre)', tuple(), ('astronomical_units',)), - ('nautical_mile', 'nmi', '(1852*metre)', tuple(), ('nauticalmiles',)), # Note: no short form in source, short form from Wikipedia - ('knot', 'kn', '((1852/3600)*metre/second)', tuple(), ('knots',)), # Note: no short form in source, short form from Wikipedia - ('are', 'a', '(10**2*metre**2)', tuple(), ('ares',)), - ('hectare', 'ha', '(10**4*metre**2)', tuple(), ('hectares',)), - ('bar', 'bar', '(10**5*pascal)', tuple(), ('bars',)), - ('barn', 'b', '(10**(-28)*metre**2)', tuple(), ('barns',)), - ('curie', 'Ci', '(3.7*10**10*becquerel)', ('Curie',), ('curies',)), - ('roentgen', 'R', '(2.58*10**(-4)*kelvin/(kilogram))', ('Roentgen', 'Röntgen'), ('roentgens', 'Roentgens')), - ('rad', 'rad', '(10**(-2)*gray)', tuple(), ('rads',)), - ('rem', 'rem', '(10**(-2)*sievert)', tuple(), ('rems',)), -} - -""" -Imperial (UK) units taken from https://en.wikipedia.org/wiki/Imperial_units and converted to SI base units -""" -set_of_imperial_units = { - ('inch', 'in', '(0.0254*metre)', tuple(), ('inches',)), - ('foot', 'ft', '(0.3048*metre)', tuple(), ('feet',)), - ('yard', 'yd', '(0.9144*metre)', tuple(), ('yards',)), - ('mile', 'mi', '(1609.344*metre)', tuple(), ('miles',)), - ('fluid_ounce', 'fl_oz', '(0.00002841310625*metre**3)', tuple(), ('fluid_ounces',)), - ('gill', 'gi', '(0.0001420653125*metre**3)', tuple(), ('gills',)), - ('pint', 'pt', '(0.00056826*metre**3)', tuple(), ('pints',)), - ('quart', 'qt', '(0.0011365225*metre**3)', tuple(), ('quarts',)), - ('gallon', 'gal', '(0.00454609*metre**3)', tuple(), ('gallons',)), - ('ounce', 'oz', '(28.349523125*gram)', tuple(), ('ounces',)), - ('pound', 'lb', '(0.45359237*kilogram)', tuple(), ('pounds',)), - ('stone', 'st', '(6.35029318*kilogram)', tuple(), tuple()), -} - -""" -Conversion of other units to base SI -""" -non_base_units = set_of_common_units_in_SI | set_of_derived_SI_units_in_SI_base_units | set_of_very_common_units_in_SI | set_of_imperial_units -conversion_to_base_si_units = {x[0]: x[2] for x in non_base_units} -conversion_to_base_si_units.update({x[0]: x[0] for x in set_of_SI_base_unit_dimensions}) -temp_dict = dict() -for item in set_of_SI_prefixes: - temp_dict.update({item[0]+x: "("+item[2]+"*"+conversion_to_base_si_units[x]+")" for x in conversion_to_base_si_units.keys()}) -conversion_to_base_si_units.update(temp_dict) From 377cfbbbb0b1b600b658e179a6ab57a726e9bef3 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 11:59:19 +0000 Subject: [PATCH 06/26] Drop the lambda-feedback latex2sympy fork latex2sympy2 now comes from PyPI through compareexpressions-expression- parsing, which the packages were built and tested against. The fork's relevant change, reading `x = 2` as an equation rather than an assignment, is reproduced by the package's parse_latex. The app suite's responses are identical on both builds. A targeted probe of LaTeX inputs (derivatives, equations, \pm, relations, norms, units, sums, integrals) only differs for `\mathrm{T}`, which PyPI's release reads as `T`: - `5~\mathrm{T}` previews as `5*T` (the fork gave the invalid `5*\mathrm{T}`), and physical-quantity evaluation of such LaTeX no longer fails with PARSE_ERROR. - `x^{\mathrm{T}}` is read as a transpose and previews as `x`; the fork gave the invalid `x**\mathrm{T}`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- app/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/requirements.txt b/app/requirements.txt index a0b463e..f7bc743 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -2,7 +2,6 @@ pydot mpmath==1.3.0 sympy==1.14 antlr4-python3-runtime==4.7.2 -git+https://github.com/lambda-feedback/latex2sympy.git@master#egg=latex2sympy2 # Parsing, units and criteria packages extracted from this function # (https://github.com/lambda-feedback/compareExpressions-packages). All four are # listed: they depend on each other by name and aren't published on PyPI. From 0c70e71d2018a4d03259b029790546d505e52144 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 11:59:48 +0000 Subject: [PATCH 07/26] Document the compareexpressions packages Point the developer docs at the packages instead of the removed app/utility and app/preview_implementations modules, describe what each package provides and how feedback tags become text, and explain how to develop against a local checkout of the packages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- README.md | 4 ++++ app/docs/dev.md | 25 +++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c9040e9..a2a1bd1 100755 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ This version is specifically for python, however the ultimate goal is to make si The function is built on top of a custom base layer, [BaseEvaluationFunctionLayer](https://github.com/lambda-feedback/BaseEvalutionFunctionLayer), which tools, tests and schema checking relevant to all evaluation functions. +### Packages + +Expression parsing, unit handling and the criteria machinery come from the [compareExpressions-packages](https://github.com/lambda-feedback/compareExpressions-packages) repository, installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. + ### Docker & Amazon Web Services (AWS) The grading scripts are hosted AWS Lambda, using containers to run a docker image of the app. Docker is a popular tool in software development that allows programs to be hosted on any machine by bundling all its requirements and dependencies into a single file called an **image**. diff --git a/app/docs/dev.md b/app/docs/dev.md index 7ada921..f3e5a3d 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -48,7 +48,7 @@ The overall flow of the evaluation procedure can be described as follows: ### Context The context is a data structure that contains at least the following seven pieces of information: -- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `utility\expression_utilities.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.py`. +- `default_parameters` A dictionary where the keys are parameter names and the values are the default values that the evaluation function will use unless another value is provided together with the response. The required fields are context-dependent, currently all contexts use the default parameters found in `context\symbolic.py` and the `physical_quantity` context adds a few extra fields, see the default parameters defined in `context\physical_quantity.py`. - `expression_parse` function that parses expressions (i.e. the `response` and `answer` inputs) into the form used by the feedback generation procedure. - `expression_preprocess` function that performs string manipulations that makes ensures that correctly written input expressions follows the conventions expected by `expression_parse`. - `expression_preview` is a function that generates a string that can be turned into a human-readable representation of how the evaluation function interpreted the response. @@ -206,11 +206,32 @@ The function returns result dictionary with the following fields: # Preview function -When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions can be found in `preview_implementations`. +When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions come from the `compareexpressions-expression-parsing` (symbolic expressions) and `compareexpressions-units` (physical quantities) packages, see [Packages](#packages). **Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case the `preview.py` and `evaluation.py` could also share the same code for determining the right context to use. `E` is treated as `E` in when `is_latex` is enabled. As latex2sympy2 does not parse E correctly. More details can be found ont the PR: https://github.com/lambda-feedback/compareExpressions/pull/235 +# Packages + +The parsing, unit-handling and criteria code lives in four packages from [compareExpressions-packages](https://github.com/lambda-feedback/compareExpressions-packages), installed through `requirements.txt`: + +| Package | Import | Used for | +|---|---|---| +| `compareexpressions-slr-parsing` | `compareexpressions.slr_parsing` | The SLR parser behind the criteria and quantity grammars | +| `compareexpressions-criteria` | `compareexpressions.criteria` | Parsing criteria, criteria graphs, turning reached criteria into feedback | +| `compareexpressions-expression-parsing` | `compareexpressions.expression_parsing` | Preprocessing and parsing expressions with SymPy, LaTeX, the symbolic preview | +| `compareexpressions-units` | `compareexpressions.units` | Unit data, reading physical quantities, dimensional analysis, the quantity preview | + +The packages don't contain feedback text. Where they report something to the learner they return a `FeedbackTag` (a tag plus the inputs needed to render it), and `internal_feedback` in `feedback/symbolic.py` and `feedback/physical_quantity.py` turns it into text. `result.py` holds the `EvaluationResult` that collects feedback and serialises the response. + +To work on the app and the packages together, install the packages from a local checkout in editable mode instead of from git: + +```bash +pip install -e ../compareExpressions-packages/slr_parsing -e ../compareExpressions-packages/criteria \ + -e ../compareExpressions-packages/expression_parsing -e ../compareExpressions-packages/units +grep -v compareexpressions app/requirements.txt | pip install -r /dev/stdin +``` + # Tests There are two main groups of tests, evaluation tests and preview tests. The evaluation test can be run by calling `evaluation_tests.py` \ No newline at end of file From 6accd121379934b6f8ad5b5cb77f99732e2e55d7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Sat, 12 Sep 2026 18:05:40 +0000 Subject: [PATCH 08/26] Install the packages from m-messer/compare-expressions-packages The packages repo is published at github.com/m-messer/compare-expressions- packages, not under lambda-feedback, and has no v0.2.0 tag. Pin all four packages to the 0.2.0 commit on its main branch (fe90efe) and point the docs at the real repository. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BnD49HmKpYCXbgnY3V4GMJ --- README.md | 2 +- app/docs/dev.md | 2 +- app/requirements.txt | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a2a1bd1..7f4f45b 100755 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The function is built on top of a custom base layer, [BaseEvaluationFunctionLaye ### Packages -Expression parsing, unit handling and the criteria machinery come from the [compareExpressions-packages](https://github.com/lambda-feedback/compareExpressions-packages) repository, installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. +Expression parsing, unit handling and the criteria machinery come from the [compareExpressions-packages](https://github.com/m-messer/compare-expressions-packages) repository, installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. ### Docker & Amazon Web Services (AWS) diff --git a/app/docs/dev.md b/app/docs/dev.md index f3e5a3d..69dc4eb 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -214,7 +214,7 @@ When the evaluation function preview is called the code in `preview.py` will be # Packages -The parsing, unit-handling and criteria code lives in four packages from [compareExpressions-packages](https://github.com/lambda-feedback/compareExpressions-packages), installed through `requirements.txt`: +The parsing, unit-handling and criteria code lives in four packages from [compareExpressions-packages](https://github.com/m-messer/compare-expressions-packages), installed through `requirements.txt`: | Package | Import | Used for | |---|---|---| diff --git a/app/requirements.txt b/app/requirements.txt index f7bc743..c15b810 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,9 +3,10 @@ mpmath==1.3.0 sympy==1.14 antlr4-python3-runtime==4.7.2 # Parsing, units and criteria packages extracted from this function -# (https://github.com/lambda-feedback/compareExpressions-packages). All four are -# listed: they depend on each other by name and aren't published on PyPI. -compareexpressions-slr-parsing @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=slr_parsing -compareexpressions-criteria @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=criteria -compareexpressions-expression-parsing @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=expression_parsing -compareexpressions-units @ git+https://github.com/lambda-feedback/compareExpressions-packages.git@v0.2.0#subdirectory=units +# (https://github.com/m-messer/compare-expressions-packages), pinned to a commit +# on its main branch (0.2.0). All four are listed: they depend on each other by +# name and aren't published on PyPI. +compareexpressions-slr-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=slr_parsing +compareexpressions-criteria @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=criteria +compareexpressions-expression-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=expression_parsing +compareexpressions-units @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=units From 6e319346c53c1d313e5cb1ab9a2f69905931f9f7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 14:18:58 +0100 Subject: [PATCH 09/26] "Added physical quantity preview implementation and associated tests" --- README.md | 0 app/context/physical_quantity.py | 9 +- app/context/physical_quantity_preview.py | 91 ++++++++++++ app/preview.py | 2 +- app/preview_test.py | 4 +- app/tests/physical_quantity_preview_test.py | 151 ++++++++++++++++++++ 6 files changed, 252 insertions(+), 5 deletions(-) mode change 100755 => 100644 README.md create mode 100644 app/context/physical_quantity_preview.py create mode 100644 app/tests/physical_quantity_preview_test.py diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 50075b5..3729983 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -10,8 +10,9 @@ compute_relative_tolerance_from_significant_decimals, parse_expression, ) -from compareexpressions.units import QuantityParams, preprocess_quantity, preview_function +from compareexpressions.units import QuantityParams, preprocess_quantity from compareexpressions.units import parse_quantity as parse_physical_quantity +from .physical_quantity_preview import preview_function from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ..feedback.physical_quantity import internal_feedback from .symbolic import default_parameters as symbolic_default_parameters @@ -19,6 +20,10 @@ from sympy import Symbol +def expression_preprocess(name, expr, parameters): + return preprocess_quantity(name, expr, QuantityParams.from_dict(parameters)) + + def parse_quantity(name, expr, parameters, evaluation_result): quantity = parse_physical_quantity(expr, parameters["quantity"], name) for (tag, message) in quantity.messages: @@ -510,7 +515,7 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as context = { "expression_preview": preview_function, "generate_criteria_parser": generate_criteria_parser, - "expression_preprocess": preprocess_quantity, + "expression_preprocess": expression_preprocess, "expression_parse": parse_quantity, "default_parameters": default_parameters, "default_criteria": default_criteria, diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py new file mode 100644 index 0000000..3e42670 --- /dev/null +++ b/app/context/physical_quantity_preview.py @@ -0,0 +1,91 @@ +"""Previewing a quantity response: LaTeX and SymPy renderings of value and unit.""" + +from __future__ import annotations + +import tokenize +from collections.abc import Mapping +from typing import Any + +from compareexpressions.expression_parsing import ( + Preview, + Result, + SympyParsingConfig, + find_matching_parenthesis, + parse_expression, + parse_latex, + sanitise_latex, + sympy_to_latex, +) +from compareexpressions.units import ( + QuantityError, + QuantityParams, + QuantityParseError, + parse_quantity, + preprocess_quantity, +) + + +def fix_exponents(response: str) -> str: + """Rewrite LaTeX exponents ``^{...}`` / ``**{...}`` as ``**(...)``. + + Only a brace directly after the exponent operator is unwrapped; other + exponents (``x^2``) just get ``**``. + """ + for notation in ("^", "**"): + processed = [] + index = 0 + while index < len(response): + exponent_start = response.find(notation, index) + if exponent_start < 0: + processed.append(response[index:]) + break + processed.append(response[index:exponent_start] + "**") + index = exponent_start + len(notation) + if response.startswith("{", index): + exponent_end = find_matching_parenthesis(response, index, delimiters=("{", "}")) + if exponent_end > 0: + processed.append("(" + response[index + 1 : exponent_end] + ")") + index = exponent_end + 1 + response = "".join(processed) + return response + + +def preview_function(response: str, params: QuantityParams | Mapping[str, Any]) -> Result: + """Preview a quantity response as LaTeX and SymPy syntax. + + ``params`` may be :class:`QuantityParams` or evaluation-function + parameters. Raises :class:`QuantityParseError` if the response can't be + read as a quantity. + """ + params = params if isinstance(params, QuantityParams) else QuantityParams.from_dict(params) + if not response: + return Result(preview=Preview(latex="", sympy="")) + + try: + if params.is_latex: + response = fix_exponents(sanitise_latex(response)) + quantity = parse_quantity(response, params) + value, unit = quantity.value, quantity.unit + value_latex = value_sympy = "" + if value is not None: + value_string = parse_latex(value.content_string(), params.symbols, params.simplify) + value_expr = parse_expression(value_string, SympyParsingConfig.from_params(params)) + value_latex = sympy_to_latex(value_expr, params.symbols) + value_sympy = str(value_expr) + separators = ("~", " ") if value is not None and unit is not None else ("", "") + unit_latex = quantity.unit_latex if unit is not None else "" + unit_sympy = unit.content_string() if unit is not None else "" + latex_out = value_latex + separators[0] + (unit_latex or "") + sympy_out = value_sympy + separators[1] + unit_sympy + else: + preprocessed = preprocess_quantity("response", response, params).expression + latex_out = parse_quantity(preprocessed, params).latex + sympy_out = response + except QuantityError: + raise + except (SyntaxError, tokenize.TokenError) as e: + raise QuantityParseError("Failed to parse Sympy expression") from e + except ValueError as e: + raise QuantityParseError("Failed to parse LaTeX expression") from e + + return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) \ No newline at end of file diff --git a/app/preview.py b/app/preview.py index 134f940..bef90f7 100644 --- a/app/preview.py +++ b/app/preview.py @@ -3,7 +3,7 @@ from compareexpressions.expression_parsing import Result from compareexpressions.expression_parsing import preview_function as symbolic_preview -from compareexpressions.units import preview_function as physical_quantity_preview +from .context.physical_quantity_preview import preview_function as physical_quantity_preview def preview_function(response: str, params: Mapping[str, Any]) -> Result: diff --git a/app/preview_test.py b/app/preview_test.py index 56e50ef..8967d7b 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -22,8 +22,8 @@ class TestPreviewFunction(): Use preview_function() to check your algorithm works as it should. - The symbolic and physical quantity previews themselves are tested in the - compareexpressions-expression-parsing and compareexpressions-units packages. + The symbolic preview itself is tested in the compareexpressions-expression-parsing + package. The physical quantity preview is tested in physical_quantity_preview_test.py. """ def test_empty_latex_expression(self): diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py new file mode 100644 index 0000000..009dd1b --- /dev/null +++ b/app/tests/physical_quantity_preview_test.py @@ -0,0 +1,151 @@ +import os +from copy import deepcopy + +import pytest + +from ..context.physical_quantity_preview import fix_exponents, preview_function +from .physical_quantity_test_cases import slr_natural_si_syntax_test_cases, slr_strict_si_syntax_test_cases + + +class TestPreviewFunction: + """ + TestCase Class used to test the algorithm. + --- + Tests are used here to check that the algorithm written + is working as it should. + + It's best practise to write these tests first to get a + kind of 'specification' for how your algorithm should + work, and you should run these tests before committing + your code to AWS. + + Read the docs on how to use unittest here: + https://docs.python.org/3/library/unittest.html + + Use preview_function() to check your algorithm works + as it should. + """ + + @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", + "elementary_functions": True, + } + result = preview_function(response, params)["preview"] + latex = "" + if value_latex is None and unit_latex is not None: + latex = unit_latex + elif value_latex is not None and unit_latex is None: + latex = value_latex + elif value_latex is not None and unit_latex is not None: + latex = value_latex + "~" + unit_latex + assert result["latex"] == 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", + "elementary_functions": True, + } + result = preview_function(response, params)["preview"] + latex = "" + if value_latex is None and unit_latex is not None: + latex = unit_latex + elif value_latex is not None and unit_latex is None: + latex = value_latex + elif value_latex is not None and unit_latex is not None: + latex = value_latex + "~" + unit_latex + assert result["latex"] == latex + + @pytest.mark.parametrize( + "response,preview_latex,preview_sympy", + [ + ("sin(123)", r"\sin{\left(123 \right)}", "sin(123)"), + ("sqrt(162)", r"\sqrt{162}", "sqrt(162)"), + ], + ) + def test_issue_with_function_name_that_can_be_compound_unit(self, response, preview_latex, preview_sympy): + params = { + "physical_quantity": True, + "elementary_functions": True, + } + result = preview_function(response, params)["preview"] + assert result["latex"] == preview_latex + assert result["sympy"] == preview_sympy + + def test_handwritten_input(self): + params = { + "is_latex": True, + "physical_quantity": True, + "elementary_functions": True, + } + response = "162 \\mathrm{~N} / \\mathrm{m}^{2}" + result = preview_function(response, params)["preview"] + assert ( + result["latex"] == r"162~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}" + ) # TODO: Fix so that unnecessary parenthesis are simplified away + assert result["sympy"] == "162 newton/metre**(2)" + + @pytest.mark.parametrize( + "response, latex, sympy", + [ + (r"\frac{F}{p \cdot \mu}", r"\frac{F}{\mu p}", "F/((mu*p))"), + (r"F \cdot p", "F p", "F*p"), + (r"\frac{10}{2} \mathrm{~kg}", r"\frac{10}{2}~\mathrm{kilogram}", "10/2 kilogram"), + ], + ) + def test_latex_physical_quantity_preview(self, response, latex, sympy): + params = { + "is_latex": True, + "physical_quantity": True, + "elementary_functions": True, + } + result = preview_function(response, params)["preview"] + assert result["latex"] == latex + assert result["sympy"] == sympy + + @pytest.mark.parametrize( + ("latex", "expected"), + [ + ("m^{-2}", "m**(-2)"), + ("x^{23}", "x**(23)"), + # With '**' the notation used to be skipped twice, eating the exponent's first character. + ("m**{-2}", "m**(-2)"), + ("x**{23}", "x**(23)"), + ("x^2", "x**2"), + # An unbraced exponent used to borrow the next brace anywhere in the string. + ("x^2y^{3}", "x**2y**(3)"), + ], + ) + def test_fix_exponents(self, latex, expected): + assert fix_exponents(latex) == expected + + def test_latex_preview_keeps_the_exponent_sign(self): + preview = preview_function(r"162 \mathrm{~N} \mathrm{~m}**{-2}", {"is_latex": True})["preview"] + assert preview["sympy"] == "162 newton metre**(-2)" + + def test_latex_preview_of_a_unit_alone(self): + assert preview_function(r"\mathrm{kg}", {"is_latex": True})["preview"]["sympy"] == "kilogram" + + def test_preview_does_not_mutate_params(self): + # Fixed while porting units to the typed expression_parsing API + # (the LaTeX branch set params["is_latex"] = False on the caller's dict). + params = {"is_latex": True, "symbols": {"F": {"latex": "F", "aliases": []}}} + before = deepcopy(params) + preview_function(r"3 \mathrm{kg}", params) + assert params == before + + +if __name__ == "__main__": + pytest.main(["-xk not slow", "--tb=line", os.path.abspath(__file__)]) \ No newline at end of file From 572c54759cd1c6bb059ed9f699be883e61ca2c2a Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 16 Sep 2026 15:48:49 +0100 Subject: [PATCH 10/26] Add symbolic_preview.py and feedback/tags.py, moved from expression_parsing expression_parsing 0.3.0 drops preview_function, parse_symbolic, Preview, Result, ExpressionSyntaxError, FeedbackTag and FeedbackTagName: rendering a response for display, and mapping a parse failure to a learner-facing tag, are app-layer concerns. Not yet wired up to anything (next commit). Co-Authored-By: Claude Sonnet 5 --- app/feedback/tags.py | 35 +++++++++++ app/symbolic_preview.py | 132 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 app/feedback/tags.py create mode 100644 app/symbolic_preview.py diff --git a/app/feedback/tags.py b/app/feedback/tags.py new file mode 100644 index 0000000..e11a2a7 --- /dev/null +++ b/app/feedback/tags.py @@ -0,0 +1,35 @@ +"""Feedback tags raised by the compareexpressions packages, and this app's own preview logic. + +The packages don't carry compareExpressions' feedback text: a +:class:`FeedbackTag` is the tag plus the inputs needed to render it (see +``feedback/symbolic.py`` and ``feedback/physical_quantity.py`` for the +tag -> text mapping). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class FeedbackTagName(StrEnum): + """Tags emitted while previewing or preprocessing a symbolic response.""" + + BRACKET_NOTATION_MISMATCH = "BRACKET_NOTATION_MISMATCH" + """Brackets closed with a different kind than opened (inputs: ``x``, the expression).""" + + ABSOLUTE_VALUE_NOTATION_AMBIGUITY = "ABSOLUTE_VALUE_NOTATION_AMBIGUITY" + """``|...|`` pairs that could not be matched unambiguously (inputs: ``name``, e.g. ``"response"``).""" + + PARSE_ERROR = "PARSE_ERROR" + """The expression could not be parsed (inputs: ``response``).""" + + +@dataclass(frozen=True) +class FeedbackTag: + """A feedback tag and the inputs needed to render its text.""" + + tag: str + inputs: Mapping[str, Any] = field(default_factory=dict) diff --git a/app/symbolic_preview.py b/app/symbolic_preview.py new file mode 100644 index 0000000..442a064 --- /dev/null +++ b/app/symbolic_preview.py @@ -0,0 +1,132 @@ +"""Previewing a learner's response: LaTeX and SymPy renderings of what was typed. + +Moved here from ``compareexpressions.expression_parsing``: rendering a +response for display, and mapping a parse failure to a tag for the learner, +are app-layer concerns. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, NotRequired, TypedDict + +from sympy import Basic +from sympy.parsing.sympy_parser import T as TRANSFORMATIONS + +from compareexpressions.expression_parsing import ( + AbsoluteValueNotationError, + BracketNotationError, + ExpressionParams, + ExpressionParsingError, + SympyParsingConfig, + convert_absolute_notation, + create_expression_set, + parse_expression, + parse_latex, + preprocess_expression, + substitute_input_symbols, + sympy_symbols, + sympy_to_latex, +) + +from .feedback.tags import FeedbackTag, FeedbackTagName + + +class ExpressionSyntaxError(ExpressionParsingError): + """A response could not be parsed; ``feedback`` is the tag to show the learner.""" + + def __init__(self, feedback: FeedbackTag) -> None: + super().__init__(f"{feedback.tag}: {dict(feedback.inputs)}") + self.feedback = feedback + + +class Preview(TypedDict): + """Structurally compatible with ``lf_toolkit.preview.Preview`` (whose ``feedback`` is required).""" + + latex: str + sympy: str + feedback: NotRequired[str] + + +class Result(TypedDict): + """Structurally compatible with ``lf_toolkit.preview.Result``.""" + + preview: Preview + + +def _as_params(params: ExpressionParams | Mapping[str, Any]) -> ExpressionParams: + return params if isinstance(params, ExpressionParams) else ExpressionParams.from_dict(params) + + +def parse_symbolic(response: str, params: ExpressionParams) -> tuple[list[Basic | set[Basic]], list[FeedbackTag]]: + """Parse a (preprocessed) response into SymPy expressions, one per expression it stands for. + + Also returns the feedback raised while converting ``|...|`` notation. + Raises :class:`ExpressionSyntaxError` (with ``PARSE_ERROR`` feedback) + when an expression can't be parsed. + """ + responses = [] + feedback: list[FeedbackTag] = [] + for expression in create_expression_set(response, params): + expression = substitute_input_symbols([expression.strip()], params)[0] + try: + expression = convert_absolute_notation(expression, "response") + except AbsoluteValueNotationError as exc: + feedback.append(FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": exc.name})) + expression = exc.expression + responses.append(expression) + + config = SympyParsingConfig.from_params(params) + config = config.replace( + extra_transformations=TRANSFORMATIONS[9], # convert equals signs + symbol_dict={**config.symbol_dict, **sympy_symbols(params.symbols)}, + ) + parsed = [] + for expression in responses: + try: + parsed.append(parse_expression(expression, config)) + except Exception as exc: + raise ExpressionSyntaxError(FeedbackTag(FeedbackTagName.PARSE_ERROR, {"response": expression})) from exc + return parsed, feedback + + +def preview_function(response: str, params: ExpressionParams | Mapping[str, Any]) -> Result: + """Preview a learner's response as LaTeX and SymPy syntax. + + ``params`` may be :class:`ExpressionParams` or evaluation-function + parameters. Each side of an ``=`` is previewed separately; a response + standing for several expressions (``{a, b}``, ``±``) previews as a set. + Raises :class:`ExpressionParsingError` if the response can't be parsed. + """ + params = _as_params(params) + if not response: + return Result(preview=Preview(latex="", sympy="")) + + latex_parts = [] + sympy_parts = [] + for part in response.split("="): + try: + if params.is_latex: + latex_out = [part] + sympy_out = [parse_latex(part, params.symbols, params.simplify)] + else: + symbolic_params = params.replace(rationalise=False) + try: + preprocessed = preprocess_expression("response", part, symbolic_params) + except (BracketNotationError, AbsoluteValueNotationError) as exc: + preprocessed = exc.expression + expressions, _ = parse_symbolic(preprocessed, symbolic_params) + latex_out = sorted( + sympy_to_latex(expression, params.symbols, settings={"mul_symbol": r" \cdot "}) + for expression in expressions + ) + sympy_out = [preprocessed] + except ExpressionSyntaxError as exc: + raise ExpressionParsingError(f"Failed to parse SymPy expression: {response}") from exc + except ValueError as exc: + raise ExpressionParsingError(f"Failed to parse LaTeX expression: {response}") from exc + + sympy_parts.append(sympy_out[0] if len(sympy_out) == 1 else str(sympy_out)) + latex_parts.append(latex_out[0] if len(latex_out) == 1 else "\\left\\{" + ",~".join(latex_out) + "\\right\\}") + + return Result(preview=Preview(latex="=".join(latex_parts), sympy="=".join(sympy_parts))) From f0551d1cd059c7a638b03ba0204d71a9d66bd072 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 16 Sep 2026 15:54:37 +0100 Subject: [PATCH 11/26] Wire up the moved symbolic preview in preview.py and context/symbolic.py Both imported preview_function/Result from compareexpressions.expression_parsing; point them at the new local symbolic_preview module instead. Co-Authored-By: Claude Sonnet 5 --- app/context/symbolic.py | 2 +- app/preview.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 0bf5b05..4c88be2 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -8,9 +8,9 @@ generate_arbitrary_number_pattern_matcher, parse_expression, preprocess_expression, - preview_function, ) +from ..symbolic_preview import preview_function from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from compareexpressions.criteria import CriteriaGraph, build_criteria_parser diff --git a/app/preview.py b/app/preview.py index bef90f7..0dca3cd 100644 --- a/app/preview.py +++ b/app/preview.py @@ -1,8 +1,8 @@ from collections.abc import Mapping from typing import Any -from compareexpressions.expression_parsing import Result -from compareexpressions.expression_parsing import preview_function as symbolic_preview +from .symbolic_preview import Result +from .symbolic_preview import preview_function as symbolic_preview from .context.physical_quantity_preview import preview_function as physical_quantity_preview From 416aed091a62044885b6663915525ec93ac756c7 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 16 Sep 2026 15:55:28 +0100 Subject: [PATCH 12/26] Rebuild physical_quantity_preview.py against the new expression_parsing/units APIs Preview/Result now come from the local symbolic_preview module (expression_parsing no longer has them), and preprocess_quantity returns a plain string instead of a Preprocessed with .expression. Co-Authored-By: Claude Sonnet 5 --- app/context/physical_quantity_preview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py index 3e42670..ccf67f0 100644 --- a/app/context/physical_quantity_preview.py +++ b/app/context/physical_quantity_preview.py @@ -7,8 +7,6 @@ from typing import Any from compareexpressions.expression_parsing import ( - Preview, - Result, SympyParsingConfig, find_matching_parenthesis, parse_expression, @@ -24,6 +22,8 @@ preprocess_quantity, ) +from ..symbolic_preview import Preview, Result + def fix_exponents(response: str) -> str: """Rewrite LaTeX exponents ``^{...}`` / ``**{...}`` as ``**(...)``. @@ -78,7 +78,7 @@ def preview_function(response: str, params: QuantityParams | Mapping[str, Any]) latex_out = value_latex + separators[0] + (unit_latex or "") sympy_out = value_sympy + separators[1] + unit_sympy else: - preprocessed = preprocess_quantity("response", response, params).expression + preprocessed = preprocess_quantity("response", response, params) latex_out = parse_quantity(preprocessed, params).latex sympy_out = response except QuantityError: From a8f1837522bef630c01970a64b89b701a6b36230 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 16 Sep 2026 16:33:18 +0100 Subject: [PATCH 13/26] Catch typed notation exceptions in evaluation.py instead of checking Preprocessed.success expression_parsing's preprocess_expression (and units' preprocess_quantity, which never raises these) now raises BracketNotationError/AbsoluteValueNotationError instead of returning a Preprocessed(expression, feedback). Both call sites become try/except, reproducing the same warn-for-learner/fatal-for-answer split as before. Co-Authored-By: Claude Sonnet 5 --- app/evaluation.py | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index 7c2f32a..492fc24 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,13 +1,19 @@ from copy import deepcopy from compareexpressions.criteria import add_feedback_from_tags -from compareexpressions.expression_parsing import ExpressionParams, parse_latex +from compareexpressions.expression_parsing import ( + AbsoluteValueNotationError, + BracketNotationError, + ExpressionParams, + parse_latex, +) from .result import EvaluationResult from .context.symbolic import context as symbolic_context from .context.physical_quantity import context as quantity_context from .feedback.symbolic import feedback_generators as symbolic_feedback_string_generators from .feedback.symbolic import internal_feedback +from .feedback.tags import FeedbackTag, FeedbackTagName from collections.abc import Mapping @@ -16,6 +22,13 @@ } +def notation_error_feedback(error, original_text): + """`FeedbackTag` for a `BracketNotationError`/`AbsoluteValueNotationError` raised while preprocessing.""" + if isinstance(error, BracketNotationError): + return FeedbackTag(FeedbackTagName.BRACKET_NOTATION_MISMATCH, {"x": original_text}) + return FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": error.name}) + + class FrozenValuesDictionary(dict): """ A dictionary where new key:value pairs can be added, @@ -117,15 +130,17 @@ def parse_reserved_expressions(reserved_expressions, parameters, result): reserved_expressions_dict.update({key: FrozenValuesDictionary()}) for (label, expr) in reserved_expressions[key].items(): expr_parsed = None - preprocessed = preprocess(key, expr, parameters) - expr = preprocessed.expression - if preprocessed.success is False: - feedback_tag = str(preprocessed.feedback.tag) + original_expr = expr + try: + expr = preprocess(key, expr, parameters) + except (BracketNotationError, AbsoluteValueNotationError) as e: + feedback = notation_error_feedback(e, original_expr) + expr = e.expression if key == "learner": # Preprocess can only create warnings for responses in this case - result.add_feedback(feedback_tag, internal_feedback(preprocessed.feedback)) + result.add_feedback(str(feedback.tag), internal_feedback(feedback)) else: - raise Exception(internal_feedback(preprocessed.feedback), feedback_tag) + raise Exception(internal_feedback(feedback), str(feedback.tag)) from e reserved_expressions[key][label] = expr if not isinstance(expr, str): raise Exception(f"Reserved expression {label} must be given as a string.") @@ -166,11 +181,12 @@ def get_criteria_string(parameters): def create_criteria_dict(criteria_parser, parsing_params): preprocess = parsing_params["context"]["expression_preprocess"] - criteria_string = get_criteria_string(parsing_params) - preprocessed = preprocess("criteria", criteria_string, parsing_params) - criteria_string = preprocessed.expression - if preprocessed.success is False: - raise Exception(internal_feedback(preprocessed.feedback), str(preprocessed.feedback.tag)) + original_criteria_string = get_criteria_string(parsing_params) + try: + criteria_string = preprocess("criteria", original_criteria_string, parsing_params) + except (BracketNotationError, AbsoluteValueNotationError) as e: + feedback = notation_error_feedback(e, original_criteria_string) + raise Exception(internal_feedback(feedback), str(feedback.tag)) from e criteria_string_list = [] delims = [ ("(", ")"), From c48fe3a0a3581a8710665ad60507865b472baa65 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Wed, 16 Sep 2026 21:35:17 +0100 Subject: [PATCH 14/26] Update physical_quantity to handle reverted_unit feedback Replaced `internal_feedback` with `reverted_unit_feedback` and updated the feedback mechanism to support `reverted_units`, adding detailed contextual information. --- app/context/physical_quantity.py | 6 +++--- app/feedback/physical_quantity.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 3729983..20cf69c 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -14,7 +14,7 @@ from compareexpressions.units import parse_quantity as parse_physical_quantity from .physical_quantity_preview import preview_function from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators -from ..feedback.physical_quantity import internal_feedback +from ..feedback.physical_quantity import reverted_unit_feedback from .symbolic import default_parameters as symbolic_default_parameters from compareexpressions.slr_parsing import SLRParser, catch_undefined, create_node, infix, operate from sympy import Symbol @@ -26,8 +26,8 @@ def expression_preprocess(name, expr, parameters): def parse_quantity(name, expr, parameters, evaluation_result): quantity = parse_physical_quantity(expr, parameters["quantity"], name) - for (tag, message) in quantity.messages: - evaluation_result.add_feedback(tag, internal_feedback(message)) + for index, reverted in enumerate(quantity.reverted_units): + evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) if quantity.standard_value is not None: standard_value = quantity.standard_value else: diff --git a/app/feedback/physical_quantity.py b/app/feedback/physical_quantity.py index 989ae5d..905af92 100644 --- a/app/feedback/physical_quantity.py +++ b/app/feedback/physical_quantity.py @@ -22,6 +22,8 @@ }[tag] -def internal_feedback(feedback_tag): - """Text for a `FeedbackTag` raised by the compareexpressions packages (e.g. `REVERTED_UNIT`).""" - return feedback_string_generators["INTERNAL"](str(feedback_tag.tag))(feedback_tag.inputs) +def reverted_unit_feedback(reverted_unit): + """Text for a `RevertedUnit` fact from the units package.""" + return feedback_string_generators["INTERNAL"]("REVERTED_UNIT")( + {"before": reverted_unit.before, "marked": reverted_unit.marked, "after": reverted_unit.after} + ) From 53ac036e11a7bb81f48868ff7f5ec74c1c8b923c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 25 Sep 2026 19:13:49 +0100 Subject: [PATCH 15/26] Replace `add_feedback_from_tags` with `CriteriaGraph.export_feedback` for improved feedback handling Updated `evaluation.py` and `result.py` to switch from `add_feedback_from_tags` to `CriteriaGraph.export_feedback`. Streamlined feedback generation and modified related test data serialization logic. --- app/evaluation.py | 3 +-- app/result.py | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index 492fc24..6238a9a 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,6 +1,5 @@ from copy import deepcopy -from compareexpressions.criteria import add_feedback_from_tags from compareexpressions.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, @@ -238,7 +237,7 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): # assumption that some way to return partial feedback # before script has executed completely will be available # in the future - add_feedback_from_tags(evaluation_result, criteria_feedback, graph, custom_feedback=custom_feedback) + graph.export_feedback(evaluation_result, criteria_feedback, custom_feedback=custom_feedback) evaluation_result.is_correct = is_correct return diff --git a/app/result.py b/app/result.py index 39d82aa..4ed861b 100644 --- a/app/result.py +++ b/app/result.py @@ -1,4 +1,4 @@ -from compareexpressions.criteria import criteria_test_data +from compareexpressions.criteria import CriteriaGraph class EvaluationResult: @@ -6,8 +6,8 @@ class EvaluationResult: Collects the outcome of an evaluation and serialises it into the response returned by `evaluation_function`. - Satisfies `compareexpressions.criteria.ResultLike`, so the feedback - helpers in `compareexpressions.criteria.feedback` can add to it. + Satisfies `compareexpressions.criteria.ResultLike`, so + `CriteriaGraph.export_feedback` can add to it. """ def __init__(self): self.is_correct = False @@ -33,7 +33,7 @@ def _serialise_feedback(self): def serialise(self, include_test_data=False) -> dict: out = dict(is_correct=self.is_correct, feedback=self._serialise_feedback(), tags=self.tags) if include_test_data is True: - out.update(criteria_test_data(self.criteria_graphs)) + out.update(CriteriaGraph.test_data(self.criteria_graphs)) if self.latex is not None: out.update(dict(response_latex=self.latex)) if self.simplified is not None: From 686d3d9c5847ca1517d0daf540db8260e12cd5d3 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 25 Sep 2026 19:39:22 +0100 Subject: [PATCH 16/26] Pin the packages at 2848e58 (CriteriaGraph.export_feedback) Co-Authored-By: Claude Opus 5.5 --- app/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/requirements.txt b/app/requirements.txt index c15b810..f792cc3 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -6,7 +6,7 @@ antlr4-python3-runtime==4.7.2 # (https://github.com/m-messer/compare-expressions-packages), pinned to a commit # on its main branch (0.2.0). All four are listed: they depend on each other by # name and aren't published on PyPI. -compareexpressions-slr-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=slr_parsing -compareexpressions-criteria @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=criteria -compareexpressions-expression-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=expression_parsing -compareexpressions-units @ git+https://github.com/m-messer/compare-expressions-packages.git@fe90efe3a2501816c755fea39033f84fc33f483c#subdirectory=units +compareexpressions-slr-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=slr_parsing +compareexpressions-criteria @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=criteria +compareexpressions-expression-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=expression_parsing +compareexpressions-units @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=units From 541162c4b7485951d4fe1ca839e001825c0db7a5 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 25 Sep 2026 23:07:40 +0100 Subject: [PATCH 17/26] Use the renamed lambdafeedback packages The extracted packages are now `lambdafeedback-*` (imports `lambdafeedback.`) in m-messer/lambda-feedback-packages. Pin them at 865d399 and update the imports and docs. The packages checkout no longer lives inside this repo, so drop its .gitignore entry. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 --- README.md | 2 +- app/context/physical_quantity.py | 10 +++++----- app/context/physical_quantity_preview.py | 4 ++-- app/context/symbolic.py | 4 ++-- app/docs/dev.md | 18 +++++++++--------- app/evaluation.py | 2 +- app/feedback/symbolic.py | 2 +- app/feedback/tags.py | 2 +- app/preview_test.py | 2 +- app/requirements.txt | 10 +++++----- app/result.py | 4 ++-- app/symbolic_preview.py | 4 ++-- app/tests/physical_quantity_evaluation_test.py | 2 +- app/tests/physical_quantity_test_cases.py | 2 +- app/tests/symbolic_evaluation_test.py | 4 ++-- 16 files changed, 36 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 12b0788..14885ce 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,3 @@ app/benchmark.py app/profiling_examples.py .idea/ - -# Extracted packages live here as their own separate git repo -/compareExpressions-packages/ diff --git a/README.md b/README.md index 7f4f45b..b2a4369 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The function is built on top of a custom base layer, [BaseEvaluationFunctionLaye ### Packages -Expression parsing, unit handling and the criteria machinery come from the [compareExpressions-packages](https://github.com/m-messer/compare-expressions-packages) repository, installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. +Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/m-messer/lambda-feedback-packages) repository, installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. ### Docker & Amazon Web Services (AWS) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 20cf69c..6b0a9c5 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -3,20 +3,20 @@ # ------- from copy import deepcopy -from compareexpressions.criteria import CriteriaGraph -from compareexpressions.expression_parsing import ( +from lambdafeedback.criteria import CriteriaGraph +from lambdafeedback.expression_parsing import ( ExpressionParams, SympyParsingConfig, compute_relative_tolerance_from_significant_decimals, parse_expression, ) -from compareexpressions.units import QuantityParams, preprocess_quantity -from compareexpressions.units import parse_quantity as parse_physical_quantity +from lambdafeedback.units import QuantityParams, preprocess_quantity +from lambdafeedback.units import parse_quantity as parse_physical_quantity from .physical_quantity_preview import preview_function from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ..feedback.physical_quantity import reverted_unit_feedback from .symbolic import default_parameters as symbolic_default_parameters -from compareexpressions.slr_parsing import SLRParser, catch_undefined, create_node, infix, operate +from lambdafeedback.slr_parsing import SLRParser, catch_undefined, create_node, infix, operate from sympy import Symbol diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py index ccf67f0..5b7be30 100644 --- a/app/context/physical_quantity_preview.py +++ b/app/context/physical_quantity_preview.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from typing import Any -from compareexpressions.expression_parsing import ( +from lambdafeedback.expression_parsing import ( SympyParsingConfig, find_matching_parenthesis, parse_expression, @@ -14,7 +14,7 @@ sanitise_latex, sympy_to_latex, ) -from compareexpressions.units import ( +from lambdafeedback.units import ( QuantityError, QuantityParams, QuantityParseError, diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 4c88be2..6fd8223 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,7 +1,7 @@ from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo from sympy import re as real_part -from compareexpressions.expression_parsing import ( +from lambdafeedback.expression_parsing import ( PATTERNS as syntactical_forms, ExpressionParams, SympyParsingConfig, @@ -13,7 +13,7 @@ from ..symbolic_preview import preview_function from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators -from compareexpressions.criteria import CriteriaGraph, build_criteria_parser +from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser default_parameters = { diff --git a/app/docs/dev.md b/app/docs/dev.md index 69dc4eb..32fbed3 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -206,7 +206,7 @@ The function returns result dictionary with the following fields: # Preview function -When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions come from the `compareexpressions-expression-parsing` (symbolic expressions) and `compareexpressions-units` (physical quantities) packages, see [Packages](#packages). +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 come from the `lambdafeedback-expression-parsing` (symbolic expressions) and `lambdafeedback-units` (physical quantities) packages, see [Packages](#packages). **Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case the `preview.py` and `evaluation.py` could also share the same code for determining the right context to use. @@ -214,23 +214,23 @@ When the evaluation function preview is called the code in `preview.py` will be # Packages -The parsing, unit-handling and criteria code lives in four packages from [compareExpressions-packages](https://github.com/m-messer/compare-expressions-packages), installed through `requirements.txt`: +The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/m-messer/lambda-feedback-packages), installed through `requirements.txt`: | Package | Import | Used for | |---|---|---| -| `compareexpressions-slr-parsing` | `compareexpressions.slr_parsing` | The SLR parser behind the criteria and quantity grammars | -| `compareexpressions-criteria` | `compareexpressions.criteria` | Parsing criteria, criteria graphs, turning reached criteria into feedback | -| `compareexpressions-expression-parsing` | `compareexpressions.expression_parsing` | Preprocessing and parsing expressions with SymPy, LaTeX, the symbolic preview | -| `compareexpressions-units` | `compareexpressions.units` | Unit data, reading physical quantities, dimensional analysis, the quantity preview | +| `lambdafeedback-slr-parsing` | `lambdafeedback.slr_parsing` | The SLR parser behind the criteria and quantity grammars | +| `lambdafeedback-criteria` | `lambdafeedback.criteria` | Parsing criteria, criteria graphs, turning reached criteria into feedback | +| `lambdafeedback-expression-parsing` | `lambdafeedback.expression_parsing` | Preprocessing and parsing expressions with SymPy, LaTeX, the symbolic preview | +| `lambdafeedback-units` | `lambdafeedback.units` | Unit data, reading physical quantities, dimensional analysis, the quantity preview | The packages don't contain feedback text. Where they report something to the learner they return a `FeedbackTag` (a tag plus the inputs needed to render it), and `internal_feedback` in `feedback/symbolic.py` and `feedback/physical_quantity.py` turns it into text. `result.py` holds the `EvaluationResult` that collects feedback and serialises the response. To work on the app and the packages together, install the packages from a local checkout in editable mode instead of from git: ```bash -pip install -e ../compareExpressions-packages/slr_parsing -e ../compareExpressions-packages/criteria \ - -e ../compareExpressions-packages/expression_parsing -e ../compareExpressions-packages/units -grep -v compareexpressions app/requirements.txt | pip install -r /dev/stdin +pip install -e ../lambda-feedback-packages/slr_parsing -e ../lambda-feedback-packages/criteria \ + -e ../lambda-feedback-packages/expression_parsing -e ../lambda-feedback-packages/units +grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin ``` # Tests diff --git a/app/evaluation.py b/app/evaluation.py index 6238a9a..7ab4328 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,6 +1,6 @@ from copy import deepcopy -from compareexpressions.expression_parsing import ( +from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, ExpressionParams, diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index d6c1ee8..b65cfb9 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -80,5 +80,5 @@ def internal_feedback(feedback_tag): - """Text for a `FeedbackTag` raised by the compareexpressions packages (e.g. `BRACKET_NOTATION_MISMATCH`).""" + """Text for a `FeedbackTag` raised by the lambdafeedback packages (e.g. `BRACKET_NOTATION_MISMATCH`).""" return feedback_generators["INTERNAL"](str(feedback_tag.tag))(feedback_tag.inputs) diff --git a/app/feedback/tags.py b/app/feedback/tags.py index e11a2a7..9bac22e 100644 --- a/app/feedback/tags.py +++ b/app/feedback/tags.py @@ -1,4 +1,4 @@ -"""Feedback tags raised by the compareexpressions packages, and this app's own preview logic. +"""Feedback tags raised by the lambdafeedback packages, and this app's own preview logic. The packages don't carry compareExpressions' feedback text: a :class:`FeedbackTag` is the tag plus the inputs needed to render it (see diff --git a/app/preview_test.py b/app/preview_test.py index 8967d7b..2a60151 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -22,7 +22,7 @@ class TestPreviewFunction(): Use preview_function() to check your algorithm works as it should. - The symbolic preview itself is tested in the compareexpressions-expression-parsing + The symbolic preview itself is tested in the lambdafeedback-expression-parsing package. The physical quantity preview is tested in physical_quantity_preview_test.py. """ diff --git a/app/requirements.txt b/app/requirements.txt index f792cc3..8896bdd 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,10 +3,10 @@ mpmath==1.3.0 sympy==1.14 antlr4-python3-runtime==4.7.2 # Parsing, units and criteria packages extracted from this function -# (https://github.com/m-messer/compare-expressions-packages), pinned to a commit +# (https://github.com/m-messer/lambda-feedback-packages), pinned to a commit # on its main branch (0.2.0). All four are listed: they depend on each other by # name and aren't published on PyPI. -compareexpressions-slr-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=slr_parsing -compareexpressions-criteria @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=criteria -compareexpressions-expression-parsing @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=expression_parsing -compareexpressions-units @ git+https://github.com/m-messer/compare-expressions-packages.git@2848e58af94b04674b044ff557bf698a7e59fedf#subdirectory=units +lambdafeedback-slr-parsing @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=slr_parsing +lambdafeedback-criteria @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=criteria +lambdafeedback-expression-parsing @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=expression_parsing +lambdafeedback-units @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=units diff --git a/app/result.py b/app/result.py index 4ed861b..081d837 100644 --- a/app/result.py +++ b/app/result.py @@ -1,4 +1,4 @@ -from compareexpressions.criteria import CriteriaGraph +from lambdafeedback.criteria import CriteriaGraph class EvaluationResult: @@ -6,7 +6,7 @@ class EvaluationResult: Collects the outcome of an evaluation and serialises it into the response returned by `evaluation_function`. - Satisfies `compareexpressions.criteria.ResultLike`, so + Satisfies `lambdafeedback.criteria.ResultLike`, so `CriteriaGraph.export_feedback` can add to it. """ def __init__(self): diff --git a/app/symbolic_preview.py b/app/symbolic_preview.py index 442a064..60c6bd2 100644 --- a/app/symbolic_preview.py +++ b/app/symbolic_preview.py @@ -1,6 +1,6 @@ """Previewing a learner's response: LaTeX and SymPy renderings of what was typed. -Moved here from ``compareexpressions.expression_parsing``: rendering a +Moved here from ``lambdafeedback.expression_parsing``: rendering a response for display, and mapping a parse failure to a tag for the learner, are app-layer concerns. """ @@ -13,7 +13,7 @@ from sympy import Basic from sympy.parsing.sympy_parser import T as TRANSFORMATIONS -from compareexpressions.expression_parsing import ( +from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, BracketNotationError, ExpressionParams, diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index a3bdc78..20ef8c2 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -4,7 +4,7 @@ # Import necessary data and reference cases for tests from .physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases from ..evaluation import evaluation_function -from compareexpressions.units import ( +from lambdafeedback.units import ( COMMON_UNITS, IMPERIAL_UNITS, SI_BASE_UNITS, diff --git a/app/tests/physical_quantity_test_cases.py b/app/tests/physical_quantity_test_cases.py index 26fd31c..5908a47 100644 --- a/app/tests/physical_quantity_test_cases.py +++ b/app/tests/physical_quantity_test_cases.py @@ -1,6 +1,6 @@ # Physical quantities with their expected value, unit and LaTeX forms, shared by # the physical quantity evaluation tests. The quantity parser itself is tested in -# the compareexpressions-units package. +# the lambdafeedback-units package. slr_strict_si_syntax_test_cases = [ ("q", # String that will be used as response / answer diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 7922f9c..79d05b4 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -2,8 +2,8 @@ import os from ..evaluation import evaluation_function -from compareexpressions.expression_parsing import substitute -from compareexpressions.expression_parsing.symbol_tables import ELEMENTARY_FUNCTIONS as elementary_functions_names +from lambdafeedback.expression_parsing import substitute +from lambdafeedback.expression_parsing.symbol_tables import ELEMENTARY_FUNCTIONS as elementary_functions_names # REMARK: If a case is marked with an alternative output, this means that it is difficult in this case to prevent sympy from simplifying for that particular case elementary_function_test_cases = [ From 3f4dff3fd6c798adb573cbf63888f28e9b840bb2 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 11 Sep 2026 20:34:58 +0000 Subject: [PATCH 18/26] Ignore extracted compareExpressions-packages nested repo The reusable packages extracted from this function live in /compareExpressions-packages/ as their own separate git repository. Ignore it so it is not accidentally added as an embedded repo/gitlink. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XP5RCStr93ME5UX4fzyj7y --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 14885ce..12b0788 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ app/benchmark.py app/profiling_examples.py .idea/ + +# Extracted packages live here as their own separate git repo +/compareExpressions-packages/ From 8e278078f40412eb496bb768a28139c7a4f1a4fb Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 14:09:50 +0100 Subject: [PATCH 19/26] Refactor symbolic and quantity contexts for updated lambdafeedback APIs - Replace `attach` calls with `attach_new_criterion` or `attach_new_evaluation`. - Introduce helper for unsimplified parsing with unsplittable symbols. - Add `syntactical_forms.py` for number form patterns. - Update `requirements.txt` to lambdafeedback v0.3.0-rc.1. --- README.md | 2 +- app/context/physical_quantity.py | 76 ++++++++-------- app/context/physical_quantity_preview.py | 2 +- app/context/symbolic.py | 106 ++++++++++++----------- app/docs/dev.md | 12 +-- app/feedback/syntactical_forms.py | 57 ++++++++++++ app/requirements.txt | 15 ++-- app/symbolic_preview.py | 14 +-- 8 files changed, 168 insertions(+), 116 deletions(-) create mode 100644 app/feedback/syntactical_forms.py diff --git a/README.md b/README.md index b2a4369..c0d4515 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The function is built on top of a custom base layer, [BaseEvaluationFunctionLaye ### Packages -Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/m-messer/lambda-feedback-packages) repository, installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. +Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages) repository (currently the `v0.3.0-rc.1` release candidate), installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. ### Docker & Amazon Web Services (AWS) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 6b0a9c5..c6b2ccd 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -16,22 +16,24 @@ from ..feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators from ..feedback.physical_quantity import reverted_unit_feedback from .symbolic import default_parameters as symbolic_default_parameters -from lambdafeedback.slr_parsing import SLRParser, catch_undefined, create_node, infix, operate +from lambdafeedback.slr_parsing import CatchAll, Lexeme, SLRParser, TokenPattern, create_node, infix, operate from sympy import Symbol def expression_preprocess(name, expr, parameters): - return preprocess_quantity(name, expr, QuantityParams.from_dict(parameters)) + return preprocess_quantity(expr, QuantityParams.from_dict(parameters), name=name) def parse_quantity(name, expr, parameters, evaluation_result): quantity = parse_physical_quantity(expr, parameters["quantity"], name) for index, reverted in enumerate(quantity.reverted_units): evaluation_result.add_feedback(f"{name}_REVERTED_UNIT_{index}", reverted_unit_feedback(reverted)) - if quantity.standard_value is not None: - standard_value = quantity.standard_value - else: + if quantity.standard_values is None: standard_value = None + elif len(quantity.standard_values) == 1: + standard_value = quantity.standard_values[0] + else: + standard_value = set(quantity.standard_values) if quantity.value is not None: value = parse_expression(quantity.value.content_string(), quantity.parsing_config) else: @@ -75,19 +77,19 @@ def generate_criteria_parser(reserved_expressions): } token_list = [ - (start_symbol, start_symbol), - (end_symbol, end_symbol), - (null_symbol, null_symbol), - (" *BOOL *", "BOOL"), - (" *QUANTITY *", "QUANTITY"), - (" *DIMENSION *", "DIMENSION"), - ("\( *", "START_DELIMITER"), - (" *\)", "END_DELIMITER"), - ("response", "QUANTITY"), - ("answer", "QUANTITY"), - ("INPUT", "INPUT", catch_undefined), + TokenPattern(start_symbol, start_symbol), + TokenPattern(end_symbol, end_symbol), + TokenPattern(null_symbol, null_symbol), + TokenPattern(Lexeme(("BOOL",), space_before=True, space_after=True), "BOOL"), + TokenPattern(Lexeme(("QUANTITY",), space_before=True, space_after=True), "QUANTITY"), + TokenPattern(Lexeme(("DIMENSION",), space_before=True, space_after=True), "DIMENSION"), + TokenPattern(Lexeme(("(",), space_after=True), "START_DELIMITER"), + TokenPattern(Lexeme((")",), space_before=True), "END_DELIMITER"), + TokenPattern("response", "QUANTITY"), + TokenPattern("answer", "QUANTITY"), + CatchAll("INPUT", "INPUT"), ] - token_list += [(" *"+x+" *", " "+x+" ") for x in criteria_operations] + token_list += [TokenPattern(Lexeme((x,), space_before=True, space_after=True), " "+x+" ") for x in criteria_operations] productions = [ ("START", "BOOL", create_node), @@ -127,8 +129,7 @@ def comparison_function_inner(lhs, rhs, substitutions): def comparison_base_graph(criterion, parameters, comparison_operator="=", label=None): graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) @@ -156,7 +157,7 @@ def compare_evaluate(unused_input): details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.", evaluate=compare_evaluate ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is true.", @@ -164,7 +165,7 @@ def compare_evaluate(unused_input): feedback_string_generator=physical_quantity_feedback_string_generators["COMPARISON"]("TRUE") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=f"{inputs[0]} {comparison_operator} {inputs[1]} is false.", @@ -199,8 +200,7 @@ def less_than_or_equal_node(criterion, parameters, label=None): def criterion_match_node(criterion, parameters, label=None): graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() reserved_expressions = parameters["reserved_expressions"].items() parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False) rtol = parameters["parsing_parameters"]["rtol"] @@ -334,21 +334,21 @@ def unit_comparison(unused_inputs): details=f"Converts {inputs[0]} and {inputs[1]} match to a common set of base units and compares their values.", evaluate=quantity_match ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=f"The quantities {inputs[0]} and {inputs[1]} match.", details=f"The quantities {inputs[0]} and {inputs[1]} match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MATCH") ) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=f"The quantities {inputs[0]} and {inputs[1]} does not match.", details=f"The quantities {inputs[0]} and {inputs[1]} does not match.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("QUANTITY_MISMATCH") ) - graph.attach( + graph.attach_new_criterion( label, label+"_MISSING_VALUE", summary="The response is missing a value.", @@ -356,7 +356,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_VALUE") ) graph.attach(label+"_MISSING_VALUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNEXPECTED_VALUE", summary="The response is expected only have unit(s), no value.", @@ -364,7 +364,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_VALUE") ) graph.attach(label+"_UNEXPECTED_VALUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_MISSING_UNIT", summary="The response is missing unit(s).", @@ -372,7 +372,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("MISSING_UNIT") ) graph.attach(label+"_MISSING_UNIT", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNEXPECTED_UNIT", summary="The response is expected to be a value without unit(s).", @@ -380,21 +380,21 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNEXPECTED_UNIT") ) graph.attach(label+"_UNEXPECTED_UNIT", END.label) - graph.attach( + graph.attach_new_evaluation( label+"_FALSE", label+"_DIMENSION_MATCH", summary=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", details=f"Do the dimensions of {inputs[0]} and {inputs[1]} match?", evaluate=dimension_match ) - graph.attach( + graph.attach_new_criterion( label+"_DIMENSION_MATCH", label+"_DIMENSION_MATCH"+"_TRUE", summary=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", details=f"The quantities {inputs[0]} and {inputs[1]} have the same dimensions.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("DIMENSION_MATCH") ) - graph.attach( + graph.attach_new_criterion( label+"_DIMENSION_MATCH", label+"_DIMENSION_MATCH"+"_FALSE", summary=f"The quantities {inputs[0]} and {inputs[1]} have different dimensions.", @@ -403,28 +403,28 @@ def unit_comparison(unused_inputs): ) graph.attach(label+"_DIMENSION_MATCH"+"_TRUE", END.label) graph.attach(label+"_DIMENSION_MATCH"+"_FALSE", END.label) - graph.attach( + graph.attach_new_evaluation( label+"_TRUE", label+"_UNIT_COMPARISON", summary=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", details=f"Compares how similar the units of {inputs[0]} and {inputs[1]} are.", evaluate=unit_comparison ) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_IDENTICAL", summary=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", details=f"The units of quantities {inputs[0]} and {inputs[1]} are identical.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_IDENTICAL") ) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_SIMILAR", summary=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", details=f"The units of quantities {inputs[0]} and {inputs[1]} are similar.", feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("UNIT_COMPARISON_SIMILAR") ) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", summary=f"The units of {inputs[0]} are much greater than the units of {inputs[1]}.", @@ -432,7 +432,7 @@ def unit_comparison(unused_inputs): feedback_string_generator=physical_quantity_feedback_string_generators["MATCHES"]("PREFIX_IS_LARGE") ) graph.attach(label+"_UNIT_COMPARISON"+"_PREFIX_IS_LARGE", END.label) - graph.attach( + graph.attach_new_criterion( label+"_UNIT_COMPARISON", label+"_UNIT_COMPARISON"+"_PREFIX_IS_SMALL", summary=f"The units of {inputs[0]} are much smaller than the units of {inputs[1]}.", @@ -458,7 +458,7 @@ def feedback_procedure_generator(parameters_dict): graph = graph_template(criterion, parameters_dict) for evaluation in graph.evaluations.values(): if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END + evaluation.replacement = CriteriaGraph.END_LABEL graphs.update({label: graph}) return graphs diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py index 5b7be30..fcae20a 100644 --- a/app/context/physical_quantity_preview.py +++ b/app/context/physical_quantity_preview.py @@ -78,7 +78,7 @@ def preview_function(response: str, params: QuantityParams | Mapping[str, Any]) latex_out = value_latex + separators[0] + (unit_latex or "") sympy_out = value_sympy + separators[1] + unit_sympy else: - preprocessed = preprocess_quantity("response", response, params) + preprocessed = preprocess_quantity(response, params, name="response") latex_out = parse_quantity(preprocessed, params).latex sympy_out = response except QuantityError: diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 6fd8223..81c3df1 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,17 +1,20 @@ +import dataclasses + from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo from sympy import re as real_part from lambdafeedback.expression_parsing import ( - PATTERNS as syntactical_forms, ExpressionParams, SympyParsingConfig, generate_arbitrary_number_pattern_matcher, parse_expression, + parse_expressions, preprocess_expression, ) from ..symbolic_preview import preview_function from ..feedback.symbolic import feedback_generators as symbolic_feedback_string_generators +from ..feedback.syntactical_forms import PATTERNS as syntactical_forms from lambdafeedback.criteria import CriteriaGraph, build_criteria_parser @@ -26,16 +29,25 @@ def expression_preprocess(name, expr, parameters): - return preprocess_expression(name, expr, ExpressionParams.from_dict(parameters)) + return preprocess_expression(expr, ExpressionParams.from_dict(parameters), name=name) def expression_parse(name, expr, parameters, evaluation_result): - return parse_expression(expr, parameters) + expressions = parse_expressions(expr, parameters) + return expressions[0] if len(expressions) == 1 else set(expressions) default_criteria = ["response = answer"] +def unsimplified_with_unsplittable(parsing_params, symbols): + """`parsing_params` without simplification, with `symbols` also read as single symbols.""" + return dataclasses.replace( + parsing_params.replace(simplify=False), + extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(symbols), + ) + + def check_criterion(criterion, parameters_dict, generate_feedback=True): label = criterion.label.strip() parsing_params = parameters_dict["parsing_parameters"].replace(simplify=False) @@ -68,15 +80,12 @@ def check_criterion(criterion, parameters_dict, generate_feedback=True): def create_expressions_for_comparison(criterion, parameters_dict, local_substitutions=[]): parsing_params = parameters_dict["parsing_parameters"] reserved_expressions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = parsing_params.replace( - simplify=False, - unsplittable_symbols=parsing_params.unsplittable_symbols+tuple(parameters_dict["reserved_expressions"].keys()), - ) + parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() lhs_expr = parse_expression(lhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) rhs_expr = parse_expression(rhs, parsing_params).subs(local_substitutions).subs(reserved_expressions).subs(local_substitutions) - if parsing_params.complex_numbers: + if parsing_params.params.complex_numbers: simplified_lhs_expr = lhs_expr.cancel().simplify() simplified_rhs_expr = rhs_expr.cancel().simplify() if (im(lhs_expr) != 0) or (im(lhs_expr) != 0): @@ -358,18 +367,14 @@ def equality_equivalence(unused_input): } graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() def same_symbols(unused_input): parsing_params = parameters_dict["parsing_parameters"] local_substitutions = list(parameters_dict["reserved_expressions"].items()) - parsing_params = parsing_params.replace( - simplify=False, - unsplittable_symbols=parsing_params.unsplittable_symbols+tuple(parameters_dict["reserved_expressions"].keys()), - ) + parsing_params = unsimplified_with_unsplittable(parsing_params, parameters_dict["reserved_expressions"].keys()) lsym = parse_expression(lhs, parsing_params).subs(local_substitutions) rsym = parse_expression(rhs, parsing_params).subs(local_substitutions) result = lsym.free_symbols == rsym.free_symbols @@ -406,14 +411,14 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+"="+str(rhs)+".", evaluate=set_equivalence ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(lhs)+"="+str(rhs), details=str(lhs)+" is equal to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) - graph.attach( + graph.attach_new_criterion( label, label+"_MULTIPLE_ANSWER_FAIL_ALL", summary=str(lhs)+" is not equal to "+str(rhs), @@ -421,7 +426,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_ALL") ) graph.attach(label+"_MULTIPLE_ANSWER_FAIL_ALL", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", summary="Unexpected element in response.", @@ -429,7 +434,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("MULTIPLE_ANSWER_FAIL_RESPONSE") ) graph.attach(label+"_MULTIPLE_ANSWER_FAIL_RESPONSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_MULTIPLE_ANSWER_FAIL_ANSWER", summary="Missing element in response", @@ -444,7 +449,7 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+" is equivalent to "+str(rhs)+".", evaluate=equality_equivalence ) - graph.attach( + graph.attach_new_criterion( label, label+"_UNKNOWN", summary="Cannot determine if "+str(lhs)+" is equivalent to "+str(rhs), @@ -452,7 +457,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITY_EQUIVALENCE_UNKNOWN") ) graph.attach(label+"_UNKNOWN", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(lhs)+" is equivalent to "+str(rhs), @@ -460,7 +465,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_EQUIVALENT") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=str(lhs)+" is not equivalent to "+str(rhs), @@ -468,7 +473,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EQUALITIES_NOT_EQUIVALENT") ) graph.attach(label+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_EXPRESSION_NOT_EQUALITY", summary=str(lhs)+" is an expression, not an equality.", @@ -476,7 +481,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["INTERNAL"]("EXPRESSION_NOT_EQUALITY") ) graph.attach(label+"_EXPRESSION_NOT_EQUALITY", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_EQUALITY_NOT_EXPRESSION", summary=str(lhs)+" is an equality, not an expression.", @@ -491,21 +496,21 @@ def same_symbols(unused_input): details="Checks if "+str(lhs)+"="+str(rhs)+".", evaluate=mathematical_equivalence ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(lhs)+"="+str(rhs), details=str(lhs)+" is equal to "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("TRUE") ) - graph.attach( + graph.attach_new_evaluation( label+"_TRUE", label+"_SAME_SYMBOLS", summary=str(lhs)+" has the same symbols as "+str(rhs), details=str(lhs)+" has the same (free) symbols as "+str(rhs)+".", evaluate=same_symbols ) - graph.attach( + graph.attach_new_criterion( label+"_SAME_SYMBOLS", label+"_SAME_SYMBOLS"+"_TRUE", summary=str(lhs)+" has the same symbols as "+str(rhs), @@ -513,7 +518,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("TRUE") ) graph.attach(label+"_SAME_SYMBOLS"+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label+"_SAME_SYMBOLS", label+"_SAME_SYMBOLS"+"_FALSE", summary=str(lhs)+" does not have the same symbols as "+str(rhs), @@ -521,7 +526,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["SAME_SYMBOLS"]("FALSE") ) graph.attach(label+"_SAME_SYMBOLS"+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=str(lhs)+"=\\="+str(rhs), @@ -529,7 +534,7 @@ def same_symbols(unused_input): feedback_string_generator=symbolic_feedback_string_generators["response=answer"]("FALSE") ) graph.attach(label+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNKNOWN", summary="Cannot detrmine if "+str(lhs)+"="+str(rhs), @@ -567,8 +572,7 @@ def inner(unused_input): return inner graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() @@ -592,14 +596,14 @@ def response_written_as_answer(unused_input): details=f"Checks if {rhs} is written in a known form.", evaluate=written_as_known_form(answer, rhs) ) - graph.attach( + graph.attach_new_criterion( str(rhs)+"_WRITTEN_AS_KNOWN_FORM", str(rhs)+"_WRITTEN_AS_UNKNOWN", summary=str(rhs)+" is not written in a known form.", details=str(rhs)+" is not written in a known form.", feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"]("UNKNOWN") ) - graph.attach( + graph.attach_new_evaluation( str(rhs)+"_WRITTEN_AS_UNKNOWN", label, summary="Is "+str(lhs)+" written in the same form as "+str(rhs)+".", @@ -607,14 +611,14 @@ def response_written_as_answer(unused_input): evaluate=response_written_as_answer, sufficiencies={str(rhs)+"_WRITTEN_AS_UNKNOWN"} ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=str(rhs)+" written in the same form as "+str(rhs)+".", details=str(rhs)+" written in the same form as "+str(rhs)+".", feedback_string_generator=symbolic_feedback_string_generators["SYNTACTICAL_EQUIVALENCE"]("TRUE") ) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary=str(rhs)+" written in the same form as "+str(rhs)+".", @@ -623,14 +627,14 @@ def response_written_as_answer(unused_input): ) for form_label in syntactical_forms.keys(): - graph.attach( + graph.attach_new_criterion( str(rhs)+"_WRITTEN_AS_KNOWN_FORM", str(rhs)+"_WRITTEN_AS_"+form_label, summary=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", details=str(rhs)+" in written in "+syntactical_forms[form_label].name+" form.", feedback_string_generator=symbolic_feedback_string_generators["WRITTEN_AS"](form_label), ) - graph.attach( + graph.attach_new_evaluation( str(rhs)+"_WRITTEN_AS_"+form_label, str(lhs)+"_COMPARE_FORM_TO_"+form_label, summary="Is "+str(lhs)+" written in "+syntactical_forms[form_label].name+" form.", @@ -673,15 +677,14 @@ def expression_check(unused_input): return expression_check graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() graph.add_evaluation_node( label, summary=label, details="Checks if "+expression.content_string()+" where "+", ".join([s.content_string() for s in subs])+".", evaluate=create_expression_check(expression) ) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary=expression.content_string()+" where "+", ".join([s.content_string() for s in subs]), @@ -689,7 +692,7 @@ def expression_check(unused_input): feedback_string_generator=symbolic_feedback_string_generators["response=answer_where"]("TRUE") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary="not "+expression.content_string(), @@ -741,14 +744,14 @@ def identify_reason(unused_input): label+"_"+group_label: {'criterion': criterion} for group_label in values_and_variations_group.get(str(response_value), {"UNKNOWN"}) } return reasons - graph.attach( + graph.attach_new_evaluation( label+"_FALSE", label+"_IDENTIFY_REASON", summary="Identify reason.", details="Attempt to identify why the response is incorrect.", evaluate=identify_reason ) - graph.attach( + graph.attach_new_criterion( label+"_IDENTIFY_REASON", label+"_UNKNOWN", summary="Unknown reason", @@ -764,14 +767,14 @@ def get_candidates(unused_input): return candidates for (group_label, group_info) in variation_groups.items(): - graph.attach( + graph.attach_new_criterion( label+"_IDENTIFY_REASON", label+"_"+group_label, summary=group_info["summary"](expression_to_vary, group_info["variations"]), details=group_info["details"](expression_to_vary, group_info["variations"]), feedback_string_generator=symbolic_feedback_string_generators["IDENTIFY_REASON"]("UNKNOWN") ) - graph.attach( + graph.attach_new_evaluation( label+"_"+group_label, label+"_GET_CANDIDATES_"+group_label, summary="Get candidate responses that satisfy "+expression.content_string(), @@ -785,7 +788,7 @@ def get_candidates(unused_input): if group_label != "UNKNOWN": group_candidates_eval = graph.evaluations[label+"_GET_CANDIDATES_"+group_label] if label+"_RESPONSE_CANDIDATES_"+group_label not in [edge.target.label for edge in group_candidates_eval.outgoing]: - graph.attach( + graph.attach_new_criterion( label+"_GET_CANDIDATES_"+group_label, label+"_RESPONSE_CANDIDATES_"+group_label, summary="response = "+str(value), @@ -819,10 +822,9 @@ def evaluation_node_internal(unused_input): label = criterion.content_string() graph = CriteriaGraph(label) - END = CriteriaGraph.END - graph.add_node(END) + END = graph.add_end_node() graph.add_evaluation_node(label, summary=label, details="Checks if "+label+" is true.", evaluate=evaluation_node_internal) - graph.attach( + graph.attach_new_criterion( label, label+"_TRUE", summary="True", @@ -830,7 +832,7 @@ def evaluation_node_internal(unused_input): feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("TRUE") ) graph.attach(label+"_TRUE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_FALSE", summary="True", @@ -838,7 +840,7 @@ def evaluation_node_internal(unused_input): feedback_string_generator=symbolic_feedback_string_generators["GENERIC"]("FALSE") ) graph.attach(label+"_FALSE", END.label) - graph.attach( + graph.attach_new_criterion( label, label+"_UNKNOWN", summary="True", @@ -862,7 +864,7 @@ def feedback_procedure_generator(parameters_dict): graph = graph_template(criterion, parameters_dict) for evaluation in graph.evaluations.values(): if evaluation.label in parameters_dict.get("disabled_evaluation_nodes", set()): - evaluation.replacement = CriteriaGraph.END + evaluation.replacement = CriteriaGraph.END_LABEL graphs.update({label: graph}) return graphs diff --git a/app/docs/dev.md b/app/docs/dev.md index 32fbed3..7aef1d2 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -206,24 +206,24 @@ The function returns result dictionary with the following fields: # Preview function -When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions come from the `lambdafeedback-expression-parsing` (symbolic expressions) and `lambdafeedback-units` (physical quantities) packages, see [Packages](#packages). +When the evaluation function preview is called the code in `preview.py` will be executed. Since different contexts interpret responses in different ways they also have their own preview functions. The context-specific preview functions are `symbolic_preview.py` (symbolic expressions) and `context/physical_quantity_preview.py` (physical quantities), built on the parsing from the `lambdafeedback-expression-parsing` and `lambdafeedback-units` packages, see [Packages](#packages). **Remark**: Since it is likely that there will be significant overlap between the response preview and the response evaluation (e.g. code for parsing and interpreting the response), it is good practice if they can share as much code as possible to ensure consistency. For this reason it might be better to move the preview functions fully inside the context (either by making a `preview` subfolder in the `context` folder, or by moving the implementation of the preview function inside the context files themselves). In this case the `preview.py` and `evaluation.py` could also share the same code for determining the right context to use. -`E` is treated as `E` in when `is_latex` is enabled. As latex2sympy2 does not parse E correctly. More details can be found ont the PR: https://github.com/lambda-feedback/compareExpressions/pull/235 +When `is_latex` is enabled, `e` and `E` are read as plain symbols by the LaTeX parser (latex2sympy2_extended); Euler's number comes from `elementary_functions` when the result is parsed, as for text input. See the PR for the earlier latex2sympy2 workaround: https://github.com/lambda-feedback/compareExpressions/pull/235 # Packages -The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/m-messer/lambda-feedback-packages), installed through `requirements.txt`: +The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.1` release candidate. Each package's `CHANGELOG.md` lists the API changes: | Package | Import | Used for | |---|---|---| | `lambdafeedback-slr-parsing` | `lambdafeedback.slr_parsing` | The SLR parser behind the criteria and quantity grammars | | `lambdafeedback-criteria` | `lambdafeedback.criteria` | Parsing criteria, criteria graphs, turning reached criteria into feedback | -| `lambdafeedback-expression-parsing` | `lambdafeedback.expression_parsing` | Preprocessing and parsing expressions with SymPy, LaTeX, the symbolic preview | -| `lambdafeedback-units` | `lambdafeedback.units` | Unit data, reading physical quantities, dimensional analysis, the quantity preview | +| `lambdafeedback-expression-parsing` | `lambdafeedback.expression_parsing` | Preprocessing and parsing expressions with SymPy and LaTeX | +| `lambdafeedback-units` | `lambdafeedback.units` | Unit data, reading physical quantities, dimensional analysis | -The packages don't contain feedback text. Where they report something to the learner they return a `FeedbackTag` (a tag plus the inputs needed to render it), and `internal_feedback` in `feedback/symbolic.py` and `feedback/physical_quantity.py` turns it into text. `result.py` holds the `EvaluationResult` that collects feedback and serialises the response. +The packages don't contain feedback text. They raise typed exceptions (e.g. `BracketNotationError`) or return plain facts (e.g. `PhysicalQuantity.reverted_units`); the app maps these to a `FeedbackTag` (`feedback/tags.py`), and `feedback/symbolic.py` and `feedback/physical_quantity.py` turn them into text. The "written as" forms (`feedback/syntactical_forms.py`) live in the app for the same reason. `result.py` holds the `EvaluationResult` that collects feedback and serialises the response. To work on the app and the packages together, install the packages from a local checkout in editable mode instead of from git: diff --git a/app/feedback/syntactical_forms.py b/app/feedback/syntactical_forms.py new file mode 100644 index 0000000..9bfcb00 --- /dev/null +++ b/app/feedback/syntactical_forms.py @@ -0,0 +1,57 @@ +"""Written forms of numbers for "written as" criteria (moved out of lambdafeedback.expression_parsing).""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from lambdafeedback.expression_parsing import ( + is_complex_number_on_cartesian_form, + is_complex_number_on_exponential_form, + is_number, +) + + +@dataclass(frozen=True) +class SyntacticalPattern: + """A written form of numbers, for "written as" criteria. + + ``summary``/``details`` render descriptions from the criterion node (whose + two children are the compared expressions) and the parameters. + """ + + matcher: Callable[[str], bool] + name: str + summary: Callable[[Any, Any], str] + details: Callable[[Any, Any], str] + + +def _both(criterion: Any) -> str: + return f"{criterion.children[0].content_string()} and {criterion.children[1].content_string()}" + + +PATTERNS: dict[str, SyntacticalPattern] = { + "NUMBER": SyntacticalPattern( + matcher=is_number, + name="simplified number", + summary=lambda criterion, parameters: f"{_both(criterion)} are both numbers written in simplified form.", + details=lambda criterion, parameters: f"{_both(criterion)} are both numbers written in simplified form.", + ), + "CARTESIAN": SyntacticalPattern( + matcher=is_complex_number_on_cartesian_form, + name="cartesian", + summary=lambda criterion, parameters: f"{_both(criterion)} are both complex numbers written in cartesian form", + details=lambda criterion, parameters: ( + f"{_both(criterion)} are both complex numbers written in cartesian form, i.e. $a+bi$." + ), + ), + "EXPONENTIAL": SyntacticalPattern( + matcher=is_complex_number_on_exponential_form, + name="exponential", + summary=lambda criterion, parameters: ( + f"{_both(criterion)} are both complex numbers written in exponential form" + ), + details=lambda criterion, parameters: ( + f"{_both(criterion)} are both complex numbers written in exponential form, i.e. $a exp(bi)$." + ), + ), +} diff --git a/app/requirements.txt b/app/requirements.txt index 8896bdd..1821f4a 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,12 +1,11 @@ pydot mpmath==1.3.0 sympy==1.14 -antlr4-python3-runtime==4.7.2 # Parsing, units and criteria packages extracted from this function -# (https://github.com/m-messer/lambda-feedback-packages), pinned to a commit -# on its main branch (0.2.0). All four are listed: they depend on each other by -# name and aren't published on PyPI. -lambdafeedback-slr-parsing @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=slr_parsing -lambdafeedback-criteria @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=criteria -lambdafeedback-expression-parsing @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=expression_parsing -lambdafeedback-units @ git+https://github.com/m-messer/lambda-feedback-packages.git@865d39999fdf9fc63839d6b1690eab2b6f3c432d#subdirectory=units +# (https://github.com/lambda-feedback/lambda-feedback-packages), pinned to the +# v0.3.0-rc.1 release candidate (0.3.0rc1). All four are listed: they depend on +# each other by name and aren't published on PyPI. +lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=slr_parsing +lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=criteria +lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=expression_parsing +lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=units diff --git a/app/symbolic_preview.py b/app/symbolic_preview.py index 60c6bd2..350a4ca 100644 --- a/app/symbolic_preview.py +++ b/app/symbolic_preview.py @@ -11,7 +11,6 @@ from typing import Any, NotRequired, TypedDict from sympy import Basic -from sympy.parsing.sympy_parser import T as TRANSFORMATIONS from lambdafeedback.expression_parsing import ( AbsoluteValueNotationError, @@ -25,7 +24,6 @@ parse_latex, preprocess_expression, substitute_input_symbols, - sympy_symbols, sympy_to_latex, ) @@ -68,19 +66,15 @@ def parse_symbolic(response: str, params: ExpressionParams) -> tuple[list[Basic responses = [] feedback: list[FeedbackTag] = [] for expression in create_expression_set(response, params): - expression = substitute_input_symbols([expression.strip()], params)[0] + expression = substitute_input_symbols(expression.strip(), params) try: - expression = convert_absolute_notation(expression, "response") + expression = convert_absolute_notation(expression, name="response") except AbsoluteValueNotationError as exc: feedback.append(FeedbackTag(FeedbackTagName.ABSOLUTE_VALUE_NOTATION_AMBIGUITY, {"name": exc.name})) expression = exc.expression responses.append(expression) - config = SympyParsingConfig.from_params(params) - config = config.replace( - extra_transformations=TRANSFORMATIONS[9], # convert equals signs - symbol_dict={**config.symbol_dict, **sympy_symbols(params.symbols)}, - ) + config = SympyParsingConfig.from_params(params, unsplittable_symbols=params.symbols) parsed = [] for expression in responses: try: @@ -112,7 +106,7 @@ def preview_function(response: str, params: ExpressionParams | Mapping[str, Any] else: symbolic_params = params.replace(rationalise=False) try: - preprocessed = preprocess_expression("response", part, symbolic_params) + preprocessed = preprocess_expression(part, symbolic_params, name="response") except (BracketNotationError, AbsoluteValueNotationError) as exc: preprocessed = exc.expression expressions, _ = parse_symbolic(preprocessed, symbolic_params) From bd9c991891a0addfd52fe8844a48de4338693147 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 14:12:55 +0100 Subject: [PATCH 20/26] Simplify LaTeX outputs: Remove unnecessary parentheses and update notations across tests for consistency. --- app/preview_test.py | 22 ++++++++++----------- app/tests/example_test.py | 2 +- app/tests/physical_quantity_preview_test.py | 4 +--- app/tests/physical_quantity_test_cases.py | 22 ++++++++++----------- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/app/preview_test.py b/app/preview_test.py index 2a60151..63b83fa 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -84,23 +84,23 @@ def test_natural_logarithm_notation(self): ("e * x", False, False, "e \\cdot x", "e * x"), ("E", False, False, "E", "E",), ("E", True, True, "E", "E",), - ("e", True, False, "e", "E",), - ("ex", True, False, "ex", "E*x"), - ("e * x", True, False, "e * x", "E*x"), + ("e", True, False, "e", "e",), + ("ex", True, False, "ex", "e*x"), + ("e * x", True, False, "e * x", "e*x"), ("E", True, False, "E", "E",), ("ER_2", True, False, "ER_2", "E*R_2",), ("exp(1)", False, True, "e^{1}", "exp(1)"), ("e**1", False, True, "e^{1}", "E**1"), - ("e^{1}", True, True, "e^{1}", "E"), + ("e^{1}", True, True, "e^{1}", "e"), ("exp(0)", False, True, "e^{0}", "exp(0)"), ("e**0", False, True, "e^{0}", "E**0"), ("e^{0}", True, True, "e^{0}", "1"), ("exp(2)", False, True, "e^{2}", "exp(2)"), ("e**2", False, True, "e^{2}", "E**2"), - ("e^{2}", True, True, "e^{2}", "exp(2)"), + ("e^{2}", True, True, "e^{2}", "e**2"), ("exp(x)", False, True, "e^{x}", "exp(x)"), ("e**x", False, True, "e^{x}", "E**x"), - ("e^{x}", True, True, "e^{x}", "exp(x)") + ("e^{x}", True, True, "e^{x}", "e**x") ] ) def test_eulers_number_notation(self, response, is_latex, elementary_functions, response_latex, response_sympy): @@ -116,11 +116,11 @@ def test_eulers_number_notation(self, response, is_latex, elementary_functions, "response, is_latex, response_latex, response_sympy, symbols", [ ("e**ea", False, "e^{ea}", "E**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), ("e**Ea", False, "e^{ea}", "E**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), - ("e^{ea}", True, "e^{ea}", "exp(ea)", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), + ("e^{ea}", True, "e^{ea}", "e**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), ("e^{Ea}", True, "e^{Ea}", "e**ea", {"ea": {"aliases": ["ea", "Ea"], "latex": "ea"}}), ("e**aea", False, "e^{aea}", "E**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), ("e**aEa", False, "e^{aea}", "E**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), - ("e^{aea}", True, "e^{aea}", "exp(aea)", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), + ("e^{aea}", True, "e^{aea}", "e**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), ("e^{aEa}", True, "e^{aEa}", "e**aea", {"aea": {"aliases": ["aea", "aEa"], "latex": "aea"}}), ] ) @@ -252,9 +252,9 @@ def test_multi_character_implicit_multi_variable(self): @pytest.mark.parametrize( "response, is_latex, latex, sympy", [ ("A/s(e**(-a*s)-e**(-b*s))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-a*s)- E**(-b*s))"), - ("A/s(e**(-as)-e**(-bs))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-a*s)- E**(-bs))"), - ("(A/s)( e^{-a*s}-e^{-b*s})", True, r"(A/s)( e^{-a*s}-e^{-b*s})", "A*(-exp(-b*s) + exp(-a*s))/s"), - ("(A/s)( e^{-as}-e^{-bs})", True, r"(A/s)( e^{-as}-e^{-bs})", "A*(-exp(-b*s) + exp(-a*s))/s"), + ("A/s(e**(-as)-e**(-bs))", False, r"\frac{A \cdot \left(e^{- a \cdot s} - e^{- b \cdot s}\right)}{s}", "A/s( E**(-as)- E**(-bs))"), + ("(A/s)( e^{-a*s}-e^{-b*s})", True, r"(A/s)( e^{-a*s}-e^{-b*s})", "A*(-1/e**(b*s) + e**(-a*s))/s"), + ("(A/s)( e^{-as}-e^{-bs})", True, r"(A/s)( e^{-as}-e^{-bs})", "A*(-1/e**(b*s) + e**(-a*s))/s"), ] ) def test_laplace_transforms(self, response, is_latex, latex, sympy): diff --git a/app/tests/example_test.py b/app/tests/example_test.py index 4e99325..598c3cf 100644 --- a/app/tests/example_test.py +++ b/app/tests/example_test.py @@ -131,7 +131,7 @@ def test_equalities_in_the_answer_and_response(self, response, response_latex): ("109.12 foot/minute", "2.00 km/h", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, None, None, {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), ("0.556 m/s", "0.556 metre/second", r"0.556~\frac{\mathrm{metre}}{\mathrm{second}}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_IDENTICAL"}), ("5.56 dm/s", "0.556 metre/second", r"5.56~\frac{\mathrm{decimetre}}{\mathrm{second}}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), - ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{(-1)}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), + ("55.6 centimetre second^(-1)", "0.556 metre/second", r"55.6~\mathrm{centimetre}~\mathrm{second}^{-1}", True, "strict", "SI", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), ("1.24 mile/hour", "1.24 mile/hour", r"1.24~\frac{\mathrm{mile}}{\mathrm{hour}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_IDENTICAL"}), ("2 km/h", "1.24 mile/hour", r"2~\frac{\mathrm{kilometre}}{\mathrm{hour}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), # Ideally False but works with base SI units ("109.12 foot/minute", "1.24 mile/hour", r"109.12~\frac{\mathrm{foot}}{\mathrm{minute}}", True, "strict", "imperial common", {"response matches answer_TRUE", "response matches answer_UNIT_COMPARISON_SIMILAR"}), diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index 009dd1b..75a6e26 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -92,9 +92,7 @@ def test_handwritten_input(self): } response = "162 \\mathrm{~N} / \\mathrm{m}^{2}" result = preview_function(response, params)["preview"] - assert ( - result["latex"] == r"162~\frac{\mathrm{newton}}{\mathrm{metre}^{(2)}}" - ) # TODO: Fix so that unnecessary parenthesis are simplified away + assert result["latex"] == r"162~\frac{\mathrm{newton}}{\mathrm{metre}^{2}}" assert result["sympy"] == "162 newton/metre**(2)" @pytest.mark.parametrize( diff --git a/app/tests/physical_quantity_test_cases.py b/app/tests/physical_quantity_test_cases.py index 5908a47..2a75336 100644 --- a/app/tests/physical_quantity_test_cases.py +++ b/app/tests/physical_quantity_test_cases.py @@ -50,21 +50,21 @@ "kilogram/(metre second^2)", "kilogram/(metre second^2)", None, - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["NO_VALUE"]), ("metre^(3/2)", None, "metre^(3/2)", "metre^(3/2)", None, - r"\mathrm{metre}^{(\frac{3}{2})}", + r"\mathrm{metre}^{\frac{3}{2}}", ["NO_VALUE"]), ("10 kilogram/(metre second^2)", "10", "kilogram/(metre second^2)", "10 kilogram/(metre second^2)", "10", - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10 kilogram*metre/second**2", "10", @@ -85,7 +85,7 @@ "kilogram*metre*second**(-2)", "10 kilogram*metre*second**(-2)", "10", - r"\mathrm{kilogram}\cdot\mathrm{metre}\cdot\mathrm{second}^{(-2)}", + r"\mathrm{kilogram}\cdot\mathrm{metre}\cdot\mathrm{second}^{-2}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10*pi kilogram*metre/second^2", "10*pi", @@ -105,26 +105,26 @@ "(kilogram megametre^2)/(fs^4 daA)", "(kilogram megametre^2)/(femtosecond^4 decaampere)", None, - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["NO_VALUE"]), ("(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3)", "(kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(4.3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{4.3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(5.27*pi/sqrt(11) + 5*7)^(2+2.3) (kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(2+2.3)", "(kilogram megametre^2)/(fs^4 daA)", "(5.27*pi/sqrt(11) + 5*7)^(2+2.3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(\frac{5.27 \cdot \pi}{\sqrt{11}} + 5 \cdot 7\right)^{2 + 2.3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(5*27/11 + 5*7)^(2*3) (kilogram megametre^2)/(fs^4 daA)", "(5*27/11 + 5*7)^(2*3)", "(kilogram megametre^2)/(fs^4 daA)", "(5*27/11 + 5*7)^(2*3) (kilogram megametre^2)/(femtosecond^4 decaampere)", r"\left(5 \cdot 27 \cdot \frac{1}{11} + 5 \cdot 7\right)^{2 \cdot 3}", - r"\frac{(\mathrm{kilogram}~\mathrm{megametre}^{2})}{(\mathrm{femtosecond}^{4}~\mathrm{decaampere})}", + r"\frac{\mathrm{kilogram}~\mathrm{megametre}^{2}}{\mathrm{femtosecond}^{4}~\mathrm{decaampere}}", ["FULL_QUANTITY", "EXPR_VALUE"]), ("(pi+10) kg*m/s^2", "(pi+10)", @@ -294,14 +294,14 @@ "kilogram/(metresecond^2)", "kilogram/(metre second^2)", None, - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["NO_VALUE"]), ("10 kilogram/(metresecond^2)", "10", "kilogram/(metresecond^2)", "10 kilogram/(metre second^2)", "10", - r"\frac{\mathrm{kilogram}}{(\mathrm{metre}~\mathrm{second}^{2})}", + r"\frac{\mathrm{kilogram}}{\mathrm{metre}~\mathrm{second}^{2}}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10 kilogrammetre/second**2", "10", @@ -336,7 +336,7 @@ "kilogrammetresecond**(-2)", "10 kilogram metre second**(-2)", "10", - r"\mathrm{kilogram}~\mathrm{metre}~\mathrm{second}^{(-2)}", + r"\mathrm{kilogram}~\mathrm{metre}~\mathrm{second}^{-2}", ["FULL_QUANTITY", "NUMBER_VALUE"]), ("10*pi kilogrammetre/second^2", "10*pi", From 9807764bd2824ea994fc2f890851f1464ab3ae14 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 14:58:15 +0100 Subject: [PATCH 21/26] Use SympyParsingConfig.replace for extra unsplittable symbols Co-Authored-By: Claude Opus 5.5 --- app/context/symbolic.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 81c3df1..434a37d 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,5 +1,3 @@ -import dataclasses - from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo from sympy import re as real_part @@ -42,8 +40,8 @@ def expression_parse(name, expr, parameters, evaluation_result): def unsimplified_with_unsplittable(parsing_params, symbols): """`parsing_params` without simplification, with `symbols` also read as single symbols.""" - return dataclasses.replace( - parsing_params.replace(simplify=False), + return parsing_params.replace( + simplify=False, extra_unsplittable_symbols=parsing_params.extra_unsplittable_symbols+tuple(symbols), ) From 8f0c5d076daa1a7d5609c682bf580d8138005bb3 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 15:06:16 +0100 Subject: [PATCH 22/26] Update lambdafeedback dependencies to v0.3.0-rc.2 --- app/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/requirements.txt b/app/requirements.txt index 1821f4a..94aabae 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,9 +3,9 @@ mpmath==1.3.0 sympy==1.14 # Parsing, units and criteria packages extracted from this function # (https://github.com/lambda-feedback/lambda-feedback-packages), pinned to the -# v0.3.0-rc.1 release candidate (0.3.0rc1). All four are listed: they depend on +# v0.3.0-rc.2 release candidate (0.3.0rc2). All four are listed: they depend on # each other by name and aren't published on PyPI. -lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=slr_parsing -lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=criteria -lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=expression_parsing -lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.1#subdirectory=units +lambdafeedback-slr-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=slr_parsing +lambdafeedback-criteria @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=criteria +lambdafeedback-expression-parsing @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=expression_parsing +lambdafeedback-units @ git+https://github.com/lambda-feedback/lambda-feedback-packages.git@v0.3.0-rc.2#subdirectory=units From c7d375ba422acb2cec672f81ab1312592620224f Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 15:33:27 +0100 Subject: [PATCH 23/26] Remove unused benchmarking scripts, Mermaid documentation files, and redundant test code --- .gitignore | 12 ++++ __init__.py | 0 app/Dockerfile | 30 +--------- app/benchmarking.py | 57 ------------------- app/evaluation_test.py | 9 --- app/tests/example_test.py | 21 +------ .../physical_quantity_evaluation_test.py | 24 +------- custom_comparison_with_criteria_order_0.md | 26 --------- custom_comparison_with_criteria_order_1.md | 26 --------- physical_quantity_0.md | 41 ------------- 10 files changed, 18 insertions(+), 228 deletions(-) delete mode 100644 __init__.py delete mode 100644 app/benchmarking.py delete mode 100644 custom_comparison_with_criteria_order_0.md delete mode 100644 custom_comparison_with_criteria_order_1.md delete mode 100644 physical_quantity_0.md diff --git a/.gitignore b/.gitignore index 12b0788..fc8ed43 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,15 @@ app/profiling_examples.py # Extracted packages live here as their own separate git repo /compareExpressions-packages/ + +# OS / editor / local tool files +.DS_Store +.vscode/ +.junie/ +.mcp.json + +# Local virtual environments (e.g. .venv38) +.venv*/ + +# Test reports written by CI +reports/ diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/Dockerfile b/app/Dockerfile index 823dfdd..1571a45 100755 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,6 +1,4 @@ -# Base image that bundles AWS Lambda Python 3.8 image with some middleware functions -# FROM base-eval-tmp -# FROM rabidsheep55/python-base-eval-layer +# Base image that bundles the AWS Lambda Python image with the lambda-feedback middleware (app.handler) FROM ghcr.io/lambda-feedback/baseevalutionfunctionlayer:main-3.12 RUN dnf install -y git findutils && dnf clean all @@ -13,30 +11,8 @@ ENV PYTHONPATH=/app COPY requirements.txt . RUN pip3 install -r requirements.txt -# Copy main scripts -COPY evaluation.py ./app/ -COPY evaluation_test.py ./app/ -COPY preview.py ./app/ -COPY preview_test.py ./app/ -COPY result.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 tests -COPY tests/example_test.py ./app/tests/ -COPY tests/physical_quantity_evaluation_test.py ./app/tests/ -COPY tests/physical_quantity_test_cases.py ./app/tests/ -COPY tests/symbolic_evaluation_test.py ./app/tests/ - -# Copy Documentation -COPY docs/dev.md ./app/docs/dev.md -COPY docs/user.md ./app/docs/user.md +# Copy the evaluation function source, tests and docs (exclusions in .dockerignore) +COPY . ./app/ # Set permissions so files and directories can be accessed on AWS RUN chmod 644 $(find . -type f) diff --git a/app/benchmarking.py b/app/benchmarking.py deleted file mode 100644 index 3bff3f9..0000000 --- a/app/benchmarking.py +++ /dev/null @@ -1,57 +0,0 @@ -benchmarks = [ - { - "response": "2a", - "answer": "a", - "params": { - "strict_syntax": False, - "elementary_functions": True, - "criteria": "response/answer=2", - } - }, - { - "response": "2*x**2 = 10*y**2+20", - "answer": "x**2-5*y**2-10=0", - "params": {"strict_syntax": False} - }, - { - "response": "1.24 mile/hour", - "answer": "1.24 mile/hour", - "params": { - "strict_syntax": False, - "elementary_functions": True, - "physical_quantity": True, - } - }, - { - "response": "sin(x)+2", - "answer": "sin(x)", - "params": { - "strict_syntax": False, - "elementary_functions": True, - "criteria": "Derivative(response,x)=cos(x)", - } - }, - { - "response": "cos(x)**2 + sin(x)**2 + y", - "answer": "y + 1", - "params": {"strict_syntax": False} - }, - { - "response": "log(2)/2+I*(3*pi/4 plus_minus 2*n*pi)", - "answer": "log(2)/2+I*(3*pi/4 plus_minus 2*n*pi)", - "params": { - "strict_syntax": False, - "elementary_functions": True, - } - }, - { - "response": "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)", - "answer": "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)", - "params": {"strict_syntax": False} - }, - { - "response": "-(sin(xy)y+(e^y))/(x(e^y+sin(xy)x))", - "answer": "-(y*sin(x*y) + e^(y)) / (x*(e^(y) + sin(x*y)))", - "params": {"strict_syntax": False} - }, -] diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 9bc837d..cac1425 100755 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -23,15 +23,6 @@ class TestEvaluationFunction(): Use evaluation_function() to call the evaluation function. """ - # Import tests that makes sure that mathematical expression comparison works as expected - from .tests.symbolic_evaluation_test import TestEvaluationFunction as TestSymbolicComparison - - # Import tests that makes sure that physical quantities are handled as expected - from .tests.physical_quantity_evaluation_test import TestEvaluationFunction as TestQuantities - - # Import tests that corresponds to examples in documentation and examples module - from .tests.example_test import TestEvaluationFunction as TestExamples - def test_eval_function_can_handle_latex_input(self): response = r"\sin x + x^{7}" answer = "sin(x)+x**7" diff --git a/app/tests/example_test.py b/app/tests/example_test.py index 598c3cf..d41a69e 100644 --- a/app/tests/example_test.py +++ b/app/tests/example_test.py @@ -4,17 +4,6 @@ from ..evaluation import evaluation_function from ..preview import preview_function -def create_diagram_for_documentation(filename, result): - for (index, graph) in enumerate(result["criteria_graphs_vis"].values()): - with open(filename+"_"+str(index)+".md", "w") as f: - #f.write(r''+'\n') - f.write("```mermaid\n") - for g in result["criteria_graphs_vis"].values(): - print(g) - #f.write('
\n'+g+'\n
\n') - f.write(g+"\n") - #f.write('') - f.write("```\n") class TestEvaluationFunction(): """ @@ -154,7 +143,7 @@ def test_checking_the_value_of_a_physical_quantity(self, response, answer, respo assert tags == set(result["tags"]) assert result["is_correct"] == value - def test_checking_the_value_of_a_physical_quantity_and_create_diagram_for_docs(self): + def test_checking_the_value_of_a_physical_quantity(self): params = { "strict_syntax": False, "elementary_functions": True, @@ -163,7 +152,6 @@ def test_checking_the_value_of_a_physical_quantity_and_create_diagram_for_docs(s response = "2.00 kilometre/hour" answer = "2.00 km/h" result = evaluation_function(response, answer, params, include_test_data=True) - create_diagram_for_documentation("physical_quantity", result) assert result["is_correct"] == True @pytest.mark.parametrize( @@ -596,13 +584,6 @@ def test_custom_comparison_with_criteria_order(self, response, value, tags): } answer = "2*x^2" result = evaluation_function(response, answer, params, include_test_data=True) - create_diagram_for_documentation("custom_comparison_with_criteria_order", result) -# with open("diagrams.html", "w") as f: -# f.write(r''+'\n') -# for g in result["criteria_graphs_vis"].values(): -# print(g) -# f.write('
\n'+g+'\n
\n') -# f.write('') assert result["is_correct"] is value assert set(tags) == set(result["tags"]) diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index 20ef8c2..b9a0a5e 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -32,15 +32,6 @@ class TestEvaluationFunction(): Use evaluation_function() to call the evaluation function. """ - log_details = True - - def log_details_to_file(self, details, filename): - if self.log_details: - f = open(filename, "w") - f.write(details) - f.close() - return - @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) def test_strict_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { @@ -78,7 +69,6 @@ def test_slow_quantity_alternative_names_natural_syntax(self): incorrect = [] errors = [] n = 0 - k = 0 for prefix in SI_PREFIXES: for u1 in units: for u2 in units: @@ -95,15 +85,10 @@ def test_slow_quantity_alternative_names_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - print("Incorrect: "+str((answer, response, result["response_latex"]))) - if n-k > 0: - print(k) - k += 1000 m = len(errors)+len(incorrect) details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) - self.log_details_to_file(details, "test_quantity_alternative_names_natural_syntax_log.txt") # Current number of collisions caused by concatenating two units, e.g. "barnewton" has "barn" as substring, "aremole" has "rem" as substring etc. - assert len(errors)+len(incorrect) <= 144 + assert m <= 144, details @pytest.mark.skip("Too resource intensive") def test_slow_quantity_short_forms_natural_syntax(self): @@ -112,7 +97,6 @@ def test_slow_quantity_short_forms_natural_syntax(self): incorrect = [] errors = [] n = 0 - k = 0 for prefix in SI_PREFIXES: for u1 in units: for u2 in units: @@ -128,14 +112,10 @@ def test_slow_quantity_short_forms_natural_syntax(self): continue if result["is_correct"] is False: incorrect.append((answer, response, result["response_latex"])) - if n-k > 0: - print(k) - k += 1000 m = len(errors)+len(incorrect) details = "Total: "+str(m)+"/"+str(n)+"\nIncorrect:\n"+"".join([str(x)+"\n" for x in incorrect])+"\nErrors:\n"+"".join([str(x)+"\n" for x in errors]) - self.log_details_to_file(details, "test_quantity_short_forms_natural_syntax_units_log.txt") # Current number of collisions caused by concatenating short forms for units, e.g. 'femtometre inch' and 'femtominute' both have short form 'fmin' - assert len(errors)+len(incorrect) <= 551 + assert m <= 551, details @pytest.mark.parametrize( "value,unit,small_diff,large_diff", diff --git a/custom_comparison_with_criteria_order_0.md b/custom_comparison_with_criteria_order_0.md deleted file mode 100644 index f35067d..0000000 --- a/custom_comparison_with_criteria_order_0.md +++ /dev/null @@ -1,26 +0,0 @@ -```mermaid -flowchart TD - N_0_0(["2+answer > response
---
Checks if 2+answer > response is true."]) - N_1_0["2+answer > response_TRUE
---
2+answer > response is true."] - N_1_1["2+answer > response_FALSE
---
2+answer > response is false."] - N_1_2["2+answer > response_UNKNOWN
---
2+answer > response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -flowchart TD - N_0_0(["answer <= response
---
Checks if answer <= response is true."]) - N_1_0["answer <= response_TRUE
---
answer <= response is true."] - N_1_1["answer <= response_FALSE
---
answer <= response is false."] - N_1_2["answer <= response_UNKNOWN
---
answer <= response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -``` diff --git a/custom_comparison_with_criteria_order_1.md b/custom_comparison_with_criteria_order_1.md deleted file mode 100644 index f35067d..0000000 --- a/custom_comparison_with_criteria_order_1.md +++ /dev/null @@ -1,26 +0,0 @@ -```mermaid -flowchart TD - N_0_0(["2+answer > response
---
Checks if 2+answer > response is true."]) - N_1_0["2+answer > response_TRUE
---
2+answer > response is true."] - N_1_1["2+answer > response_FALSE
---
2+answer > response is false."] - N_1_2["2+answer > response_UNKNOWN
---
2+answer > response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -flowchart TD - N_0_0(["answer <= response
---
Checks if answer <= response is true."]) - N_1_0["answer <= response_TRUE
---
answer <= response is true."] - N_1_1["answer <= response_FALSE
---
answer <= response is false."] - N_1_2["answer <= response_UNKNOWN
---
answer <= response is false."] - N_2_0{{"END
---
Evaluation completed."}} - N_1_0 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_0_0 --> N_1_1 - N_1_1 --> N_2_0 -``` diff --git a/physical_quantity_0.md b/physical_quantity_0.md deleted file mode 100644 index 12df3d3..0000000 --- a/physical_quantity_0.md +++ /dev/null @@ -1,41 +0,0 @@ -```mermaid -flowchart TD - N_0_0(["response matches answer
---
Converts QUANTITY: response tags: {} and QUANTITY: answer tags: {} match to a common set of base units and compares their values."]) - N_0_1(["response matches answer_DIMENSION_MATCH
---
Do the dimensions of QUANTITY: response tags: {} and QUANTITY: answer tags: {} match?"]) - N_0_2(["response matches answer_UNIT_COMPARISON
---
Compares how similar the units of QUANTITY: response tags: {} and QUANTITY: answer tags: {} are."]) - N_1_0["response matches answer_TRUE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} match."] - N_1_1["response matches answer_FALSE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} does not match."] - N_1_2["response matches answer_MISSING_VALUE
---
The response is missing a value."] - N_1_3["response matches answer_UNEXPECTED_VALUE
---
The response is expected only have unit(s), no value."] - N_1_4["response matches answer_MISSING_UNIT
---
The response is missing unit(s)."] - N_1_5["response matches answer_UNEXPECTED_UNIT
---
The response is expected to be a value without unit(s)."] - N_1_6["response matches answer_DIMENSION_MATCH_TRUE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} have the same dimensions."] - N_1_7["response matches answer_DIMENSION_MATCH_FALSE
---
The quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} have different dimensions."] - N_1_8["response matches answer_UNIT_COMPARISON_IDENTICAL
---
The units of quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} are identical."] - N_1_9["response matches answer_UNIT_COMPARISON_SIMILAR
---
The units of quantities QUANTITY: response tags: {} and QUANTITY: answer tags: {} are similar."] - N_1_10["response matches answer_UNIT_COMPARISON_PREFIX_IS_LARGE
---
The units of QUANTITY: response tags: {} are at least 1000 times greater than the units of QUANTITY: answer tags: {}."] - N_1_11["response matches answer_UNIT_COMPARISON_PREFIX_IS_SMALL
---
The units of QUANTITY: response tags: {} are at least 1000 times smaller than the units of QUANTITY: answer tags: {}."] - N_2_0{{"END
---
Evaluation completed."}} - N_0_2 --> N_1_9 - N_1_4 --> N_2_0 - N_0_0 --> N_1_2 - N_0_0 --> N_1_1 - N_0_1 --> N_1_7 - N_1_5 --> N_2_0 - N_0_1 --> N_1_6 - N_0_0 --> N_1_3 - N_0_2 --> N_1_11 - N_1_6 --> N_2_0 - N_0_0 --> N_1_5 - N_0_2 --> N_1_8 - N_0_2 --> N_1_10 - N_1_11 --> N_2_0 - N_1_1 --> N_0_1 - N_1_3 --> N_2_0 - N_0_0 --> N_1_0 - N_1_2 --> N_2_0 - N_1_7 --> N_2_0 - N_0_0 --> N_1_4 - N_1_10 --> N_2_0 - N_1_0 --> N_0_2 -``` From f7a1b328190bf04418cd862cfefd16afe5e456e0 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 15:38:35 +0100 Subject: [PATCH 24/26] Add development container setup with Dockerfile and configuration files - Introduce `.devcontainer/devcontainer.json` and `.devcontainer/Dockerfile` for containerized development. - Include a `.dockerignore` file to specify ignored files during build. - Configure Python, Node.js, and `claude` CLI in the devcontainer for local sandboxed testing. --- .devcontainer/Dockerfile | 37 +++++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 37 +++++++++++++++++++++++++++++++++ app/.dockerignore | 12 +++++++++++ 3 files changed, 86 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 app/.dockerignore diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..8376e0c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,37 @@ +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-slim + +# Matches the Python version used by GitHub Actions (test-lint.yml, staging-deploy.yml) +# and the production image (app/Dockerfile), so tests behave the same locally. + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + build-essential \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Node.js, required to run the Claude Code CLI +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code + +# Claude Code refuses --dangerously-skip-permissions as root unless it knows +# it's already inside an isolated sandbox (this container). +ENV IS_SANDBOX=1 + +# Run `claude` in this container with permission prompts bypassed by default, +# since the container itself (not the CLI) is the safety boundary here. This +# alias only affects the container image, never the host's `claude` config. +RUN echo "alias claude='claude --dangerously-skip-permissions'" >> /etc/bash.bashrc + +WORKDIR /workspace + +COPY app/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r /tmp/requirements.txt \ + && pip install --no-cache-dir pytest flake8 + +ENV PYTHONPATH=/workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8b4515f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +{ + "name": "compareExpressions", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", + + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/root/.claude,type=bind,consistency=cached" + ], + + "containerEnv": { + "PYTHONPATH": "/workspace", + "IS_SANDBOX": "1" + }, + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.flake8", + "anthropic.claude-code" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [] + } + } + }, + + "postCreateCommand": "pip install --no-cache-dir -r app/requirements.txt", + + "remoteUser": "root" +} diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..e6a0295 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,12 @@ +# Build files +Dockerfile +.dockerignore + +# The base image provides the app package's __init__.py and handler +__init__.py + +# Local and generated files +**/__pycache__ +**/*.py[cod] +**/.DS_Store +**/.pytest_cache From 7a091d9af967c2fc3617d924e7be56a61efc0a16 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 16:09:26 +0100 Subject: [PATCH 25/26] Remove redundant `pytest.main` calls, outdated imports, and update documentation for tests and Docker workflow --- README.md | 14 +++++++------- app/Dockerfile | 3 +++ app/context/symbolic.py | 4 ++-- app/docs/dev.md | 6 ++++-- app/evaluation_test.py | 6 ------ app/preview_test.py | 7 +------ app/tests/example_test.py | 4 ---- app/tests/physical_quantity_evaluation_test.py | 9 +++------ app/tests/physical_quantity_preview_test.py | 5 ----- app/tests/symbolic_evaluation_test.py | 5 ----- 10 files changed, 20 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c0d4515..e8b0029 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,15 @@ This version is specifically for python, however the ultimate goal is to make si - [Production API Gateway Integrations](https://eu-west-2.console.aws.amazon.com/apigateway/main/develop/integrations/attach?api=cttolq2oph&integration=qpbgva8®ion=eu-west-2&routes=0xsoy4q) 4. Merge commits into the default branch - - This will trigger the `test-and-deploy.yml` workflow, which will build the docker image, push it to a shared ECR repository, then call the backend `grading-function/ensure` route to build the necessary infrastructure to make the function available from the client app. + - This will trigger the `staging-deploy.yml` workflow (and `production-deploy.yml` on release), which will build the docker image, push it to a shared ECR repository, then call the backend `grading-function/ensure` route to build the necessary infrastructure to make the function available from the client app. 5. You are now ready to start developing your function: - Edit the `app/evaluation.py` file, which ultimately gets called when the function is given the `eval` command - - Edit the `app/evaluation_tests.py` file to add tests which get run: - - Every time you commit to this repo, before the image is built and deployed - - Whenever the `healthcheck` command is supplied to the deployed function - - Edit the `app/docs.md` file to reflect your changes. This file is baked into the function's image, and is made available using the `docs` command. This feature is used to display this function's documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website once it's been hooked up! + - Add pytest tests to `app/evaluation_test.py`, `app/preview_test.py` or `app/tests/`. They get run: + - On every pull request and before the image is built and deployed (`pytest` from the repo root) + - Whenever the `healthcheck` command is supplied to the deployed function (via `app/evaluation_tests.py`) + - Edit `app/docs/user.md` and `app/docs/dev.md` to reflect your changes. These files are baked into the function's image, and is made available using the `docs` command. This feature is used to display this function's documentation on our [Documentation](https://lambda-feedback.github.io/Documentation/) website once it's been hooked up! --- @@ -38,7 +38,7 @@ The function is built on top of a custom base layer, [BaseEvaluationFunctionLaye ### Packages -Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages) repository (currently the `v0.3.0-rc.1` release candidate), installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. +Expression parsing, unit handling and the criteria machinery come from the [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages) repository (currently the `v0.3.0-rc.2` release candidate), installed as git dependencies in `app/requirements.txt`. See the "Packages" section of `app/docs/dev.md` for what each package does and how to develop against a local checkout. ### Docker & Amazon Web Services (AWS) @@ -61,7 +61,7 @@ Once the code passes all these tests, it will then be uploaded to AWS and will b ## Pre-requisites Although all programming can be done through the GitHub interface, it is recommended you do this locally on your machine. To do this, you must have installed: -- Python 3.8 or higher. +- Python 3.12 (the version used by CI and the production image). - GitHub Desktop or the `git` CLI. diff --git a/app/Dockerfile b/app/Dockerfile index 1571a45..a28cf7c 100755 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -11,6 +11,9 @@ ENV PYTHONPATH=/app COPY requirements.txt . RUN pip3 install -r requirements.txt +# The healthcheck command runs the pytest suite (see evaluation_tests.py) +RUN pip3 install pytest + # Copy the evaluation function source, tests and docs (exclusions in .dockerignore) COPY . ./app/ diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 434a37d..d65ffdb 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -1,4 +1,4 @@ -from sympy import Add, Pow, Mul, Equality, pi, im, I, N, oo +from sympy import Add, Pow, Mul, Equality, pi, im, I, N from sympy import re as real_part from lambdafeedback.expression_parsing import ( @@ -570,7 +570,7 @@ def inner(unused_input): return inner graph = CriteriaGraph(label) - END = graph.add_end_node() + graph.add_end_node() lhs = criterion.children[0].content_string() rhs = criterion.children[1].content_string() diff --git a/app/docs/dev.md b/app/docs/dev.md index 7aef1d2..968e366 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -214,7 +214,7 @@ When `is_latex` is enabled, `e` and `E` are read as plain symbols by the LaTeX p # Packages -The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.1` release candidate. Each package's `CHANGELOG.md` lists the API changes: +The parsing, unit-handling and criteria code lives in four packages from [lambda-feedback-packages](https://github.com/lambda-feedback/lambda-feedback-packages), installed through `requirements.txt` at the `v0.3.0-rc.2` release candidate. Each package's `CHANGELOG.md` lists the API changes: | Package | Import | Used for | |---|---|---| @@ -234,4 +234,6 @@ grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin ``` # Tests -There are two main groups of tests, evaluation tests and preview tests. The evaluation test can be run by calling `evaluation_tests.py` \ No newline at end of file +There are two main groups of tests, evaluation tests (`evaluation_test.py` and `tests/*_evaluation_test.py`) and preview tests (`preview_test.py` and `tests/*_preview_test.py`). Run them all with `pytest` from the repository root. + +The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. \ No newline at end of file diff --git a/app/evaluation_test.py b/app/evaluation_test.py index cac1425..ee4f6d9 100755 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -1,5 +1,4 @@ import pytest -import os from .preview import preview_function from .evaluation import evaluation_function @@ -247,8 +246,3 @@ def test_laplace_transforms(self, response, is_latex, is_correct): result = evaluation_function(response, answer, params) assert result["is_correct"] == is_correct - - - -if __name__ == "__main__": - pytest.main(['-xk not slow', '--tb=short', '--durations=10', os.path.abspath(__file__)]) diff --git a/app/preview_test.py b/app/preview_test.py index 63b83fa..56f51b1 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -1,4 +1,3 @@ -import os import pytest from .preview import preview_function @@ -171,7 +170,7 @@ def test_alias(self, response, is_latex, response_latex, response_sympy, symbols [ ("plus_minus x", False, '\\left\\{- x,~x\\right\\}', "plus_minus x"), ("\\pm x", True, '\\pm x', '{-x, x}'), - (r"\pm x^{2}+\mp y^{2}", True, "\pm x^{2}+\mp y^{2}", "{-x**2 + y**2, x**2 - y**2}"), + (r"\pm x^{2}+\mp y^{2}", True, r"\pm x^{2}+\mp y^{2}", "{-x**2 + y**2, x**2 - y**2}"), ("plus_minus x**2 + minus_plus y**2", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}", "plus_minus x**2 + minus_plus y**2"), ("- minus_plus x^2 minus_plus y^2", False, r"\left\{- x^{2} + y^{2},~x^{2} - y^{2}\right\}", "- minus_plus x^2 minus_plus y^2"), ("- minus_plus x^2 - plus_minus y^2", False, r"\left\{- x^{2} - - y^{2},~x^{2} - y^{2}\right\}", "- minus_plus x^2 - plus_minus y^2"), @@ -363,7 +362,3 @@ def test_curly_brace_multiple_answers_still_works(self, strict_syntax): result = preview_function("{1, 2}", params) assert result["preview"]["latex"] == "\\left\\{1,~2\\right\\}" assert result["preview"]["sympy"] == "{1, 2}" - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/example_test.py b/app/tests/example_test.py index d41a69e..22d33ea 100644 --- a/app/tests/example_test.py +++ b/app/tests/example_test.py @@ -1,4 +1,3 @@ -import os import pytest from ..evaluation import evaluation_function @@ -654,6 +653,3 @@ def test_criteria_custom_feedback(self, response, answer, criteria, value, feedb assert set(feedback_tags) == set(result["tags"]) for string in custom_feedback.values(): assert string in result["feedback"] - -if __name__ == "__main__": - pytest.main(['-sk not slow', "--tb=line", os.path.abspath(__file__)]) diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index b9a0a5e..ce91ece 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -1,5 +1,4 @@ import pytest -import os # Import necessary data and reference cases for tests from .physical_quantity_test_cases import slr_strict_si_syntax_test_cases, slr_natural_si_syntax_test_cases @@ -297,6 +296,7 @@ def test_print_floating_point_approximation_of_very_large_numbers(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is False + @pytest.mark.filterwarnings("ignore:legacy_preprocessing is deprecated:DeprecationWarning") def test_legacy_strictness(self): ans = "100*kilo*pascal*ohm" res = "100 kilopascal ohm" @@ -305,7 +305,8 @@ def test_legacy_strictness(self): 'physical_quantity': True, 'strictness': 'legacy', } - result = evaluation_function(res, ans, params, include_test_data=True) + with pytest.warns(DeprecationWarning, match="legacy_preprocessing is deprecated"): + result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is True ans = "8650*watt" res = "8.65kW" @@ -562,7 +563,3 @@ def test_greek_letter_units(self, ans, res): } result = evaluation_function(res, ans, params) assert result["is_correct"] is True - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--no-header", os.path.abspath(__file__)]) diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index 75a6e26..ca6e5f1 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -1,4 +1,3 @@ -import os from copy import deepcopy import pytest @@ -143,7 +142,3 @@ def test_preview_does_not_mutate_params(self): before = deepcopy(params) preview_function(r"3 \mathrm{kg}", params) assert params == before - - -if __name__ == "__main__": - pytest.main(["-xk not slow", "--tb=line", os.path.abspath(__file__)]) \ No newline at end of file diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 79d05b4..737cebc 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -1,5 +1,4 @@ import pytest -import os from ..evaluation import evaluation_function from lambdafeedback.expression_parsing import substitute @@ -2248,7 +2247,3 @@ def test_greek_letters_in_expressions(self, unicode_expr, letter_expr): } result = evaluation_function(response, answer, params) assert result["is_correct"] is True - - -if __name__ == "__main__": - pytest.main(['-xk not slow', "--tb=line", '--durations=10', os.path.abspath(__file__)]) From 8e9502533eb0b763d5341ee26cd3f8f9ede3bf50 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 13 Oct 2026 16:35:36 +0100 Subject: [PATCH 26/26] Mark slow-running tests with `@pytest.mark.slow` and update healthcheck logic to exclude them --- app/conftest.py | 2 ++ app/context/physical_quantity.py | 11 ---------- app/context/physical_quantity_preview.py | 4 ++-- app/context/symbolic.py | 11 ---------- app/docs/dev.md | 2 +- app/evaluation.py | 19 +++++++---------- app/evaluation_test.py | 3 --- app/evaluation_tests.py | 21 +++++++++++++++++++ app/feedback/symbolic.py | 6 +++--- app/preview_test.py | 4 ++-- app/preview_tests.py | 9 ++++++++ app/tests/example_test.py | 9 +++++--- .../physical_quantity_evaluation_test.py | 5 ++++- app/tests/physical_quantity_preview_test.py | 1 + app/tests/symbolic_evaluation_test.py | 19 +++++++++++++---- 15 files changed, 73 insertions(+), 53 deletions(-) create mode 100644 app/conftest.py create mode 100644 app/evaluation_tests.py create mode 100644 app/preview_tests.py diff --git a/app/conftest.py b/app/conftest.py new file mode 100644 index 0000000..adb5e82 --- /dev/null +++ b/app/conftest.py @@ -0,0 +1,2 @@ +def pytest_configure(config): + config.addinivalue_line("markers", "slow: excluded from the deployed healthcheck (see evaluation_tests.py)") diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index c6b2ccd..b178fc9 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -463,16 +463,6 @@ def feedback_procedure_generator(parameters_dict): return graphs -def feedback_string_generator(tags, graph, parameters_dict): - strings = dict() - for tag in tags: - # feedback_string = graph.criteria[tag].feedback_string_generator(inputs) - feedback_string = "PLACEHOLDER" - if feedback_string is not None: - strings.update({tag: feedback_string}) - return - - # The evaluation parameters that affect how quantities are read. Other parameters # (e.g. `symbols`, `units_string` and `is_latex`) have never applied when reading # the response and answer as quantities during evaluation, only in the preview. @@ -520,6 +510,5 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as "default_parameters": default_parameters, "default_criteria": default_criteria, "feedback_procedure_generator": feedback_procedure_generator, - "feedback_string_generator": feedback_string_generator, "parsing_parameters_generator": parsing_parameters_generator, } diff --git a/app/context/physical_quantity_preview.py b/app/context/physical_quantity_preview.py index fcae20a..427510e 100644 --- a/app/context/physical_quantity_preview.py +++ b/app/context/physical_quantity_preview.py @@ -44,7 +44,7 @@ def fix_exponents(response: str) -> str: if response.startswith("{", index): exponent_end = find_matching_parenthesis(response, index, delimiters=("{", "}")) if exponent_end > 0: - processed.append("(" + response[index + 1 : exponent_end] + ")") + processed.append("(" + response[index + 1:exponent_end] + ")") index = exponent_end + 1 response = "".join(processed) return response @@ -88,4 +88,4 @@ def preview_function(response: str, params: QuantityParams | Mapping[str, Any]) except ValueError as e: raise QuantityParseError("Failed to parse LaTeX expression") from e - return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) \ No newline at end of file + return Result(preview=Preview(latex=latex_out, sympy=sympy_out)) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index d65ffdb..04beb90 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -867,16 +867,6 @@ def feedback_procedure_generator(parameters_dict): return graphs -def feedback_string_generator(tags, graph, parameters_dict): - strings = dict() - for tag in tags: - # feedback_string = graph.criteria[tag].feedback_string_generator(inputs) - feedback_string = "PLACEHOLDER" - if feedback_string is not None: - strings.update({tag: feedback_string}) - return - - def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_assumptions=tuple()): return SympyParsingConfig.from_params(ExpressionParams.from_dict(params), unsplittable_symbols, symbol_assumptions) @@ -889,6 +879,5 @@ def parsing_parameters_generator(params, unsplittable_symbols=tuple(), symbol_as "default_parameters": default_parameters, "default_criteria": default_criteria, "feedback_procedure_generator": feedback_procedure_generator, - "feedback_string_generator": feedback_string_generator, "parsing_parameters_generator": parsing_parameters_generator, } diff --git a/app/docs/dev.md b/app/docs/dev.md index 968e366..e3782fd 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -236,4 +236,4 @@ grep -v lambdafeedback app/requirements.txt | pip install -r /dev/stdin # Tests There are two main groups of tests, evaluation tests (`evaluation_test.py` and `tests/*_evaluation_test.py`) and preview tests (`preview_test.py` and `tests/*_preview_test.py`). Run them all with `pytest` from the repository root. -The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. \ No newline at end of file +The deployed function's `healthcheck` command loads `unittest` classes from `evaluation_tests.py` and `preview_tests.py`; `evaluation_tests.py` runs the same pytest suite inside the image, so new tests only need to be written for pytest. Tests marked `@pytest.mark.slow` are left out of the healthcheck to keep it within the request timeout; mark any test function whose cases take more than about 0.5s in total. \ No newline at end of file diff --git a/app/evaluation.py b/app/evaluation.py index 7ab4328..8b7f4f4 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -72,11 +72,8 @@ def determine_context(parameters): reserved_keywords_codes = {"where", "written as", "contains", "proportional to"} reserved_keywords_aliases = {"plus_minus", "minus_plus"} - for re in parameters["reserved_expressions_strings"].values(): - reserved_keywords_aliases = reserved_keywords_aliases.union(set(re.keys())) - - for value in parameters["reserved_expressions_strings"].values(): - reserved_keywords = reserved_keywords_aliases.union(set(value.keys())) + for expressions in parameters["reserved_expressions_strings"].values(): + reserved_keywords_aliases = reserved_keywords_aliases.union(set(expressions.keys())) reserved_keywords_codes_collisions = [] for keyword in reserved_keywords_codes: @@ -90,7 +87,6 @@ def determine_context(parameters): reserved_keywords_aliases_collisions = [] for keyword in reserved_keywords_aliases: if keyword in input_symbols_reserved_aliases: - print("Collision found") reserved_keywords_aliases_collisions.append(keyword) if len(reserved_keywords_aliases_collisions) > 0: if len(reserved_keywords_aliases_collisions) == 1: @@ -98,8 +94,7 @@ def determine_context(parameters): else: raise Exception("`"+"`, `".join(reserved_keywords_aliases_collisions)+"` are reserved keywords and cannot be used as input symbol alternatives.") - reserved_keywords = reserved_keywords_codes - context.update({"reserved_keywords": list(reserved_keywords)}) + context.update({"reserved_keywords": list(reserved_keywords_codes)}) return context @@ -222,7 +217,7 @@ def generate_feedback(main_criteria, criteria_graphs, evaluation_parameters): response = evaluation_parameters["reserved_expressions"]["response"] criteria_feedback = set() is_correct = True - custom_feedback = evaluation_parameters.get("custom_feedback",{}) + custom_feedback = evaluation_parameters.get("custom_feedback", {}) for (criterion_identifier, graph) in criteria_graphs.items(): # TODO: Find better way to identify main criteria for criteria graph main_criteria = criterion_identifier+"_TRUE" @@ -287,7 +282,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di "reserved_keywords": context["reserved_keywords"]+reserved_expressions_keys, } ) - parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys) + parsing_parameters = context["parsing_parameters_generator"](parameters, unsplittable_symbols=reserved_expressions_keys) parameters.update( { "parsing_parameters": parsing_parameters, @@ -304,7 +299,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di } ) - # FIXME: Move this into expression_utilities + # FIXME: Move these notation warnings into lambdafeedback.expression_parsing if params.get("strict_syntax", False): if "^" in response: evaluation_result.add_feedback("NOTATION_WARNING_EXPONENT", symbolic_comparison_internal_messages("NOTATION_WARNING_EXPONENT")(dict())) @@ -352,7 +347,7 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di "numerical": parameters.get("numerical", False), "atol": parameters.get("atol", 0), "rtol": parameters.get("rtol", 0), - "custom_feedback": parameters.get("custom_feedback",{}), + "custom_feedback": parameters.get("custom_feedback", {}), } ) diff --git a/app/evaluation_test.py b/app/evaluation_test.py index ee4f6d9..1239a68 100755 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -106,7 +106,6 @@ def test_euler_preview_evaluate(self): result = evaluation_function(response, answer, params) assert result["is_correct"] is True - def test_theta_character_implict_multi_variable(self): params = { "strict_syntax": False, @@ -179,8 +178,6 @@ def test_e_latex(self, response, is_latex, is_correct): result = evaluation_function(response, answer, params) assert result["is_correct"] == is_correct - - @pytest.mark.parametrize("params, is_correct", [ ({}, True), ({"atol": 0.0, "rtol": 0.0, "strict_syntax": False, "physical_quantity": False, "elementary_functions": False}, True), diff --git a/app/evaluation_tests.py b/app/evaluation_tests.py new file mode 100644 index 0000000..a29f356 --- /dev/null +++ b/app/evaluation_tests.py @@ -0,0 +1,21 @@ +""" + Entry point for the `healthcheck` command of the deployed function. + + The base image's healthcheck loads `TestEvaluationFunction` from this module + with `unittest`, so this wraps a run of the pytest suite in this directory + (`*_test.py` and `tests/`). Tests marked `@pytest.mark.slow` are left out so + the healthcheck stays within the request timeout; CI runs them all. pytest + doesn't collect `*_tests.py`, so this module isn't picked up by that run. +""" +import os +import unittest + +import pytest + + +class TestEvaluationFunction(unittest.TestCase): + + def test_pytest_suite(self): + # The Lambda filesystem is read-only, so pytest's cache is disabled + exit_code = pytest.main([os.path.dirname(os.path.abspath(__file__)), "-q", "-m", "not slow", "-p", "no:cacheprovider"]) + self.assertEqual(exit_code, pytest.ExitCode.OK) diff --git a/app/feedback/symbolic.py b/app/feedback/symbolic.py index b65cfb9..69c65f4 100644 --- a/app/feedback/symbolic.py +++ b/app/feedback/symbolic.py @@ -13,13 +13,13 @@ feedback_generators = dict() feedback_generators["EQUIVALENCES"] = criteria_equivalences feedback_generators["INTERNAL"] = lambda tag: lambda inputs: { - "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name','')} might be ambiguous, use `Abs(.)` instead of `|.|`", - "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x','')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", + "ABSOLUTE_VALUE_NOTATION_AMBIGUITY": f"Notation in {inputs.get('name', '')} might be ambiguous, use `Abs(.)` instead of `|.|`", + "BRACKET_NOTATION_MISMATCH": f"Brackets in `{inputs.get('x', '')}` do not match. Every `(`, `[` or `{{` must be closed with the corresponding `)`, `]` or `}}` — different bracket types cannot be mixed to close the same group.", "NO_RESPONSE": "No response submitted.", "MULTIPLE_ANSWER_FAIL_ALL": "At least one answer or response was incorrect.", "MULTIPLE_ANSWER_FAIL_RESPONSE": "At least one response was incorrect.", "MULTIPLE_ANSWER_FAIL_ANSWER": "At least one answer is missing in the response.", - "PARSE_ERROR": f"`{inputs.get('x','')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.", + "PARSE_ERROR": f"`{inputs.get('x', '')}` could not be parsed as a valid mathematical expression. Ensure that correct codes for input symbols are used, correct notation is used, that the expression is unambiguous and that all parentheses are closed.", "NOTATION_WARNING_EXPONENT": "Note that `^` cannot be used to denote exponentiation, use `**` instead.", "NOTATION_WARNING_FACTORIAL": "Note that `!` cannot be used to denote factorial, use `factorial(...)` instead.", "NOTATION_WARNING_TRIPLE_FACTORIAL": "Note that `!!!` is not supported.", diff --git a/app/preview_test.py b/app/preview_test.py index 56f51b1..ba762d5 100644 --- a/app/preview_test.py +++ b/app/preview_test.py @@ -245,7 +245,7 @@ def test_multi_character_implicit_multi_variable(self): assert result["preview"]["sympy"] == "a/(bcd)" result = preview_function(response_implicit_no_bracket, params) - assert result["preview"]["latex"] =='\\frac{a}{bc \\cdot d}' + assert result["preview"]["latex"] == '\\frac{a}{bc \\cdot d}' assert result["preview"]["sympy"] == "a/bcd" @pytest.mark.parametrize( @@ -270,7 +270,7 @@ def test_laplace_transforms(self, response, is_latex, latex, sympy): @pytest.mark.parametrize( "response, is_latex, latex, sympy", [ - ("(x+y)*(x+z)", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)","(x+y)*(x+z)"), + ("(x+y)*(x+z)", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)", "(x+y)*(x+z)"), ("(x+y)*x+z", False, "x \\cdot \\left(x + y\\right) + z", "(x+y)*x+z"), ("x+y*(x+z)", False, "x + y \\cdot \\left(x + z\\right)", "x+y*(x+z)"), ("[x+y]*[x+z]", False, "\\left(x + y\\right) \\cdot \\left(x + z\\right)", "(x+y)*(x+z)"), diff --git a/app/preview_tests.py b/app/preview_tests.py new file mode 100644 index 0000000..11a30ca --- /dev/null +++ b/app/preview_tests.py @@ -0,0 +1,9 @@ +""" + The base image's healthcheck loads `TestPreviewFunction` from this module. + The preview tests are run as part of the pytest suite in `evaluation_tests.py`. +""" +import unittest + + +class TestPreviewFunction(unittest.TestCase): + pass diff --git a/app/tests/example_test.py b/app/tests/example_test.py index 22d33ea..8cf716e 100644 --- a/app/tests/example_test.py +++ b/app/tests/example_test.py @@ -55,6 +55,7 @@ def test_setting_input_symbols_to_be_assumed_positive_to_avoid_issues_with_fract ("+- x**2 + -+ y**2", False, r"\left\{x^{2} - y^{2},~- x^{2} + y^{2}\right\}"), ] ) + @pytest.mark.slow def test_using_plus_minus_symbols(self, response, is_latex, response_latex): answer = "plus_minus x**2 + minus_plus y**2" params = { @@ -78,7 +79,6 @@ def test_using_plus_minus_symbols(self, response, is_latex, response_latex): params.update({"is_latex": True}) # Checking latex output disabled as the function return a few different # variants of the latex in an unpredictable way - # preview = preview_function(response, params)["preview"] # assert preview["latex"] == response_latex result = evaluation_function(response, answer, params) assert result["is_correct"] is True @@ -142,7 +142,7 @@ def test_checking_the_value_of_a_physical_quantity(self, response, answer, respo assert tags == set(result["tags"]) assert result["is_correct"] == value - def test_checking_the_value_of_a_physical_quantity(self): + def test_checking_the_value_of_a_physical_quantity_long_and_short_unit_names(self): params = { "strict_syntax": False, "elementary_functions": True, @@ -151,7 +151,7 @@ def test_checking_the_value_of_a_physical_quantity(self): response = "2.00 kilometre/hour" answer = "2.00 km/h" result = evaluation_function(response, answer, params, include_test_data=True) - assert result["is_correct"] == True + assert result["is_correct"] is True @pytest.mark.parametrize( "res,ans,convention,value", @@ -195,6 +195,7 @@ def test_implicit_multiplication_convention(self, res, ans, convention, value): ) ] ) + @pytest.mark.slow def test_setting_absolute_or_relative_tolerances_for_numerical_comparison(self, answer, atol_response_true, atol_response_false, rtol_response_true, rtol_response_false): params = { "strict_syntax": False, @@ -273,6 +274,7 @@ def test_setting_absolute_or_relative_tolerances_for_numerical_comparison(self, ("cos(x)+2", "sin(x)", r"\cos{\left(x \right)} + 2", "diff(response,x)=cos(x)", False, [], dict()), ] ) + @pytest.mark.slow def test_customizing_comparison(self, response, answer, response_latex, criteria, value, feedback_tags, extra_params): params = { "strict_syntax": False, @@ -317,6 +319,7 @@ def test_using_input_symbols_alternatives(self, response): ("Derivative(T(x,t),x,x) + Derivative(q(x,t),t)/k = 1/alpha*Derivative(T(x,t),t)", True), ] ) + @pytest.mark.slow def test_MECH50001_2_24_a(self, response, value): params = { "strict_syntax": False, diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index ce91ece..276517a 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -32,6 +32,7 @@ class TestEvaluationFunction(): """ @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases) + @pytest.mark.slow def test_strict_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, @@ -47,6 +48,7 @@ def test_strict_syntax_cases(self, string, value, unit, content, value_latex, un assert result["response_latex"] == "~".join([latex for latex in [value_latex, unit_latex] if latex is not None]) @pytest.mark.parametrize("string,value,unit,content,value_latex,unit_latex,criteria", slr_natural_si_syntax_test_cases) + @pytest.mark.slow def test_natural_syntax_cases(self, string, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, @@ -127,6 +129,7 @@ def test_slow_quantity_short_forms_natural_syntax(self): ("100*10**10", "kg m/s^2", 4*10**9, 6*10**9) ] ) + @pytest.mark.slow def test_compute_relative_tolerance_from_significant_digits(self, value, unit, small_diff, large_diff): ans = value+" "+unit res_correct_under = str(eval(value)-small_diff)+" "+unit @@ -404,7 +407,6 @@ def test_answer_zero_value(self): result = evaluation_function(res, ans, params, include_test_data=True) assert result["is_correct"] is False - def test_physical_quantity_with_atol_and_kg_unit(self): ans = "20 kg" params = { @@ -555,6 +557,7 @@ def test_physical_quantity_with_rtol_and_imperial_mass_unit(self): ("30 degree", "30 °"), ] ) + @pytest.mark.slow def test_greek_letter_units(self, ans, res): params = { 'strict_syntax': False, diff --git a/app/tests/physical_quantity_preview_test.py b/app/tests/physical_quantity_preview_test.py index ca6e5f1..392ce81 100644 --- a/app/tests/physical_quantity_preview_test.py +++ b/app/tests/physical_quantity_preview_test.py @@ -28,6 +28,7 @@ class TestPreviewFunction: @pytest.mark.parametrize( "response,value,unit,content,value_latex,unit_latex,criteria", slr_strict_si_syntax_test_cases ) + @pytest.mark.slow def test_strict_syntax_cases(self, response, value, unit, content, value_latex, unit_latex, criteria): params = { "strict_syntax": False, diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 737cebc..9752e91 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -211,6 +211,7 @@ def test_complicated_expression_correct(self, response, answer): input_variations += generate_input_variations(response, answer) @pytest.mark.parametrize("response,answer", input_variations) + @pytest.mark.slow def test_simple_fractional_powers_correct(self, response, answer): params = {"strict_syntax": False, "symbol_assumptions": "('g','positive') ('v','positive')"} result = evaluation_function(response, answer, params) @@ -452,6 +453,7 @@ def test_special_functions(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_correct(self, response, answer): params = { "strict_syntax": False, @@ -467,6 +469,7 @@ def test_plus_minus_all_correct(self, response, answer): answer="+- x**2 + -+ y**2" ) ) + @pytest.mark.slow def test_plus_minus_replace_symbols_all_correct(self, response, answer): params = {"plus_minus": "+-", "minus_plus": "-+", "strict_syntax": False} result = evaluation_function(response, answer, params) @@ -479,6 +482,7 @@ def test_plus_minus_replace_symbols_all_correct(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_incorrect(self, response, answer): params = {"strict_syntax": False} result = evaluation_function(response, answer, params) @@ -503,6 +507,7 @@ def test_plus_minus_all_responses_correct(self, response, answer): answer="plus_minus x**2 + minus_plus y**2" ) ) + @pytest.mark.slow def test_plus_minus_all_responses_incorrect(self, response, answer): params = {"multiple_answers_criteria": "all_responses", "strict_syntax": False} result = evaluation_function(response, answer, params) @@ -1004,6 +1009,7 @@ def test_pi_with_rel_tol(self): ] ] ) + @pytest.mark.slow def test_PHYS40002_2_2_b(self, response, outcome): params = {"strict_syntax": False} answer = "-(y*sin(x*y) + e^(y)) / (x*(e^(y) + sin(x*y)))" @@ -1031,6 +1037,7 @@ def test_PHYS40002_2_2_b(self, response, outcome): ] ] ) + @pytest.mark.slow def test_PHYS40002_2_6_a(self, response, outcome): params = {"strict_syntax": False} answer = "6*cos(5*x+1)-90*x*sin(5*x+1)-225*x**2*cos(5*x+1)+125*x**3*sin(5*x+1)" @@ -1054,6 +1061,7 @@ def test_PHYS40002_2_6_a(self, response, outcome): [] ) ) + @pytest.mark.slow def test_elementary_functions(self, description, response, answer): params = {"strict_syntax": False, "elementary_functions": True} result = evaluation_function(response, answer, params) @@ -1272,6 +1280,7 @@ def test_no_reserved_keywords_in_old_format_input_symbol_alternatives(self): ("2*pi", "0", "tan(response) = 0", True, [], {}), ] ) + @pytest.mark.slow def test_criteria_based_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): params = { "strict_syntax": False, @@ -1892,6 +1901,7 @@ def test_syntactical_comparison(self, response, answer, criteria, value, feedbac ), ] ) + @pytest.mark.slow def test_criteria_where_numerical_comparison(self, response, answer, criteria, value, feedback_tags, additional_params): params = { "strict_syntax": False, @@ -2042,6 +2052,7 @@ def test_alternatives_to_input_symbols_takes_priority_over_elementary_function_a result = evaluation_function(response, answer, params) assert result["is_correct"] is False + @pytest.mark.slow def test_elementary_function_symbol_with_implicit_multiplication_on_both_sides(self): response = "momega^3 l^2/2(exp(2omegat)-exp(-2omegat))" answer = "m*omega^3*l^2*sinh(2*omega*t)" @@ -2053,7 +2064,7 @@ def test_elementary_function_symbol_with_implicit_multiplication_on_both_sides(s assert result["is_correct"] is True def test_curly_braces_used_for_grouping_in_response(self): - response = "2 pi e^{-a |omega|}" # {} is accepted as another way of writing () for grouping + response = "2 pi e^{-a |omega|}" # {} is accepted as another way of writing () for grouping answer = "2 pi e^(-a|omega|)" params = { 'atol': 0, @@ -2066,8 +2077,8 @@ def test_curly_braces_used_for_grouping_in_response(self): assert result["is_correct"] is True def test_unexpected_equalities_in_response_that_generates_set(self): - response = "z= plus_minus 1 + 2*i" # plus_minus generates a set of two equalities - answer = "2i plus_minus 1" # plus_minus generates a set of two expressions + response = "z= plus_minus 1 + 2*i" # plus_minus generates a set of two equalities + answer = "2i plus_minus 1" # plus_minus generates a set of two expressions params = { 'atol': 0, 'rtol': 0, @@ -2144,7 +2155,7 @@ def test_equality_with_both_sides_equal_in_response(self): } result = evaluation_function(response, answer, params) assert result["is_correct"] is False - + def test_integral(self): response = "6y" answer = "int(3*x*y, (x, 0, 2))"