Skip to content
35 changes: 35 additions & 0 deletions app/context/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""The `Context` that specialises the evaluation function for one kind of response (symbolic or physical quantity)."""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any

from lambdafeedback.criteria import CriteriaGraph
from lambdafeedback.slr_parsing import SLRParser

from app.evaluation_parameters import EvaluationParameters
from app.preview.common import Result
from app.result import EvaluationResult
from app.types import Parameters


@dataclass(frozen=True)
class Context:
# Previews the response, as the preview function would
expression_preview: Callable[[str, Parameters], Result]
# Builds the parser for the criteria from the parsed reserved expressions
generate_criteria_parser: Callable[[Mapping[str, Mapping[str, Any]]], SLRParser]
# `(name, expression, parameters)`: the expression string, ready to be parsed
expression_preprocess: Callable[[str, str, Parameters], str]
# `(name, expression, parsing_parameters, evaluation_result)`: the parsed expression
expression_parse: Callable[[str, str, Any, EvaluationResult], Any]
# Parameters used when they are not given
default_parameters: Mapping[str, Any]
# Criteria used when none are given
default_criteria: tuple[str, ...]
# Builds the criteria graph for each parsed criterion
feedback_procedure_generator: Callable[[EvaluationParameters], dict[str, CriteriaGraph]]
# `(parameters, unsplittable_symbols=...)`: the parsing parameters passed to `expression_parse`
parsing_parameters_generator: Callable[..., Any]
10 changes: 9 additions & 1 deletion app/context/graph_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Mapping
from typing import Any

from lambdafeedback.criteria import CriteriaGraph, FeedbackStringGenerator, OutputNode, ReachedCriteria
from lambdafeedback.criteria import CriteriaGraph, Evaluate, FeedbackStringGenerator, OutputNode, ReachedCriteria


def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | None = None) -> ReachedCriteria:
Expand All @@ -18,6 +18,14 @@ def outcome_tag(label: str, result: bool | None, inputs: Mapping[str, Any] | Non
return {label+"_UNKNOWN": inputs}


def outcome_graph(label: str, summary: str, details: str, evaluate: Evaluate) -> tuple[CriteriaGraph, OutputNode]:
"""A new graph `label` with an end node and a single evaluation node `label`, ready for its outcomes to be attached."""
graph = CriteriaGraph(label)
end = graph.add_end_node()
graph.add_evaluation_node(label, summary=summary, details=details, evaluate=evaluate)
return graph, end


def attach_terminal_criterion(
graph: CriteriaGraph,
evaluation_label: str,
Expand Down
31 changes: 17 additions & 14 deletions app/context/physical_quantity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

from collections.abc import Iterable
from copy import deepcopy
from types import MappingProxyType
from typing import Any

from lambdafeedback.criteria import CriteriaGraph
from lambdafeedback.expression_parsing import ExpressionParams, SympyParsingConfig
from lambdafeedback.units import QuantityParams, preprocess_quantity

from app.context.base import Context
from app.context.defaults import base_default_parameters
from app.context.physical_quantity.criteria_parser import generate_criteria_parser
from app.context.physical_quantity.graphs.match import criterion_match_node
from app.context.physical_quantity.graphs.order import greater_than_node, greater_than_or_equal_node, less_than_node, less_than_or_equal_node
from app.context.physical_quantity.parsing import parse_quantity
from app.evaluation_parameters import EvaluationParameters
from app.types import GraphTemplate, Parameters
from app.preview.physical_quantity import preview_function

Expand All @@ -30,11 +33,11 @@ def expression_preprocess(name: str, expr: str, parameters: Parameters) -> str:
}


def feedback_procedure_generator(parameters_dict: Parameters) -> dict[str, CriteriaGraph]:
def feedback_procedure_generator(parameters: EvaluationParameters) -> dict[str, CriteriaGraph]:
graphs = dict()
for (label, criterion) in parameters_dict["criteria"].items():
for (label, criterion) in parameters.criteria.items():
graph_template = graph_templates.get(criterion.label.strip(), criterion_match_node)
graphs.update({label: graph_template(criterion, parameters_dict)})
graphs.update({label: graph_template(criterion, parameters)})
return graphs


Expand Down Expand Up @@ -75,16 +78,16 @@ def parsing_parameters_generator(
)


default_criteria = ["response matches answer"]
default_criteria = ("response matches answer",)


context: dict[str, Any] = {
"expression_preview": preview_function,
"generate_criteria_parser": generate_criteria_parser,
"expression_preprocess": expression_preprocess,
"expression_parse": parse_quantity,
"default_parameters": default_parameters,
"default_criteria": default_criteria,
"feedback_procedure_generator": feedback_procedure_generator,
"parsing_parameters_generator": parsing_parameters_generator,
}
context = Context(
expression_preview=preview_function,
generate_criteria_parser=generate_criteria_parser,
expression_preprocess=expression_preprocess,
expression_parse=parse_quantity,
default_parameters=MappingProxyType(default_parameters),
default_criteria=default_criteria,
feedback_procedure_generator=feedback_procedure_generator,
parsing_parameters_generator=parsing_parameters_generator,
)
7 changes: 4 additions & 3 deletions app/context/physical_quantity/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
from lambdafeedback.slr_parsing import ExprNode

from app.context.operators import COMPARISON_OPERATORS
from app.types import Parameters, Substitutions
from app.evaluation_parameters import EvaluationParameters
from app.types import Substitutions

NONE_PLACEHOLDER = Symbol('NONE_PLACEHOLDER')


def parse_criterion_sides(criterion: ExprNode, parameters: Parameters) -> tuple[str, str, Basic, Basic]:
def parse_criterion_sides(criterion: ExprNode, parameters: EvaluationParameters) -> tuple[str, str, Basic, Basic]:
"""The left- and right-hand sides of `criterion` as strings, and parsed without simplification."""
parsing_params = parameters["parsing_parameters"]["sympy"].replace(simplify=False)
parsing_params = parameters.parsing_parameters["sympy"].replace(simplify=False)
lhs_string = criterion.children[0].content_string()
rhs_string = criterion.children[1].content_string()
return lhs_string, rhs_string, parse_expression(lhs_string, parsing_params), parse_expression(rhs_string, parsing_params)
Expand Down
54 changes: 27 additions & 27 deletions app/context/physical_quantity/graphs/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,31 @@

from app.context.graph_helpers import attach_terminal_criterion, outcome_tag
from app.context.physical_quantity.comparison import comparison_function, is_proportional, parse_criterion_sides
from app.types import Parameters
from app.evaluation_parameters import EvaluationParameters
from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators

is_equal = comparison_function("=")


def _tolerances(parameters: Parameters) -> tuple[float, float]:
def _tolerances(parameters: EvaluationParameters) -> tuple[float, float]:
"""`(rtol, atol)`, with `rtol` taken from the significant figures of the answer if no tolerance is given."""
rtol = parameters["parsing_parameters"]["rtol"]
atol = parameters["parsing_parameters"]["atol"]
if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0:
ans = parameters["reserved_expressions"]["answer"]["quantity"].value
rtol = parameters.parsing_parameters["rtol"]
atol = parameters.parsing_parameters["atol"]
if parameters.atol == 0 and parameters.rtol == 0:
ans = parameters.reserved_expressions["answer"].quantity.value
if ans is not None:
rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string())
return rtol, atol


def _missing_or_unexpected(parameters: Parameters, label: str, lhs_string: str, rhs_string: str) -> ReachedCriteria | None:
def _missing_or_unexpected(parameters: EvaluationParameters, label: str, lhs_string: str, rhs_string: str) -> ReachedCriteria | None:
"""Tag for a response missing a value or unit that the answer has (or vice versa), otherwise None."""
# TODO: Better system for identifying if we expect the response to have value/unit or not
if ('answer' in lhs_string and 'response' in rhs_string) or ('response' in lhs_string and 'answer' in rhs_string):
res_value = parameters["reserved_expressions"]["response"]["original"]["value"]
ans_value = parameters["reserved_expressions"]["answer"]["original"]["value"]
res_unit = parameters["reserved_expressions"]["response"]["original"]["unit"]
ans_unit = parameters["reserved_expressions"]["answer"]["original"]["unit"]
res_value = parameters.reserved_expressions["response"].original_value
ans_value = parameters.reserved_expressions["answer"].original_value
res_unit = parameters.reserved_expressions["response"].original_unit
ans_unit = parameters.reserved_expressions["answer"].original_unit
if res_value is None and ans_value is not None:
return {label+"_MISSING_VALUE": {"lhs": lhs_string, "rhs": rhs_string}}
if res_value is not None and ans_value is None:
Expand All @@ -47,68 +47,68 @@ def _missing_or_unexpected(parameters: Parameters, label: str, lhs_string: str,
return None


def _within_tolerance(parameters: Parameters, lhs_string: str, rhs_string: str, rtol: float, atol: float) -> bool:
def _within_tolerance(parameters: EvaluationParameters, lhs_string: str, rhs_string: str, rtol: float, atol: float) -> bool:
"""Whether the standard values of the response and answer are within the numerical tolerances."""
value_match = False
# TODO: better analysis of where `answer` is found in the criteria so that
# numerical tolerances can be applied appropriately
if rtol > 0 or atol > 0:
if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'):
ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify()
res = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify()
ans = parameters.reserved_expressions["answer"].standard_value.simplify()
res = parameters.reserved_expressions["response"].standard_value.simplify()
if (ans is not None and ans.is_constant()) and (res is not None and res.is_constant()):
if rtol > 0 and (ans != 0):
value_match = bool(abs(float((ans-res)/ans)) < rtol)
elif atol > 0 or (ans == 0):
answer_unit_factor = float(parameters["reserved_expressions"]["answer"]["quantity"].unit_factor)
answer_unit_factor = float(parameters.reserved_expressions["answer"].quantity.unit_factor)
value_match = bool(abs(float(ans-res)) < atol*answer_unit_factor)
return value_match


def _quantity_match(
parameters: Parameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str, rtol: float, atol: float
parameters: EvaluationParameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str, rtol: float, atol: float
) -> Evaluate:
reserved_expressions = parameters["reserved_expressions"].items()
reserved_expressions = parameters.reserved_expressions.items()

def quantity_match(_response: Any) -> ReachedCriteria:
missing_or_unexpected = _missing_or_unexpected(parameters, label, lhs_string, rhs_string)
if missing_or_unexpected is not None:
return missing_or_unexpected

substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions]
substitutions = [(key, expr.standard_value) for (key, expr) in reserved_expressions]
value_match = is_equal(lhs, rhs, substitutions)
if value_match is False:
value_match = _within_tolerance(parameters, lhs_string, rhs_string, rtol, atol)

substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions]
substitutions = [(key, expr.standard_unit) for (key, expr) in reserved_expressions]
unit_match = is_equal(lhs, rhs, substitutions)

return outcome_tag(label, value_match is True and unit_match is True, {"lhs": lhs_string, "rhs": rhs_string})
return quantity_match


def _dimension_match(parameters: Parameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str) -> Evaluate:
reserved_expressions = parameters["reserved_expressions"].items()
def _dimension_match(parameters: EvaluationParameters, label: str, lhs: Basic, rhs: Basic, lhs_string: str, rhs_string: str) -> Evaluate:
reserved_expressions = parameters.reserved_expressions.items()

def dimension_match(_response: Any) -> ReachedCriteria:
substitutions = [(key, expr["dimension"]) for (key, expr) in reserved_expressions]
substitutions = [(key, expr.dimension) for (key, expr) in reserved_expressions]
dimension_match, _ = is_proportional(lhs, rhs, substitutions)
return outcome_tag(label+"_DIMENSION_MATCH", dimension_match is True, {"lhs": lhs_string, "rhs": rhs_string})
return dimension_match


def _unit_comparison(parameters: Parameters, label: str, lhs: Basic, rhs: Basic) -> Evaluate:
reserved_expressions = parameters["reserved_expressions"].items()
def _unit_comparison(parameters: EvaluationParameters, label: str, lhs: Basic, rhs: Basic) -> Evaluate:
reserved_expressions = parameters.reserved_expressions.items()

def unit_comparison(_response: Any) -> ReachedCriteria:
substitutions = [(key, expr["original"]["unit"]) for (key, expr) in reserved_expressions]
substitutions = [(key, expr.original_unit) for (key, expr) in reserved_expressions]

if is_equal(lhs, rhs, substitutions) is True:
output_tags = {
label+"_UNIT_COMPARISON"+"_IDENTICAL": None
}
else:
local_substitutions = [(key, expr["quantity"].expanded_unit) for (key, expr) in reserved_expressions]
local_substitutions = [(key, expr.quantity.expanded_unit) for (key, expr) in reserved_expressions]
result, ratio = is_proportional(lhs, rhs, local_substitutions)
# `is_proportional` returns a ratio whenever the result is True
if result is True and ratio is not None and ratio >= 1000:
Expand Down Expand Up @@ -254,7 +254,7 @@ def _add_match_nodes(
)


def criterion_match_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph:
def criterion_match_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph:
graph = CriteriaGraph(label) # type: ignore[arg-type] # Bug 8 in the bug report: label is None here
END = graph.add_end_node()
rtol, atol = _tolerances(parameters)
Expand Down
22 changes: 10 additions & 12 deletions app/context/physical_quantity/graphs/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,30 @@
from lambdafeedback.criteria import CriteriaGraph, ReachedCriteria
from lambdafeedback.slr_parsing import ExprNode

from app.context.graph_helpers import attach_terminal_criterion, outcome_tag
from app.context.graph_helpers import attach_terminal_criterion, outcome_graph, outcome_tag
from app.context.physical_quantity.comparison import comparison_function, parse_criterion_sides
from app.types import Parameters
from app.evaluation_parameters import EvaluationParameters
from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators


def comparison_base_graph(
criterion: ExprNode, parameters: Parameters, comparison_operator: str, label: str | None = None
criterion: ExprNode, parameters: EvaluationParameters, comparison_operator: str, label: str | None = None
) -> CriteriaGraph:
graph = CriteriaGraph(label) # type: ignore[arg-type] # Bug 8 in the bug report: label is None here
END = graph.add_end_node()
reserved_expressions = parameters["reserved_expressions"].items()
reserved_expressions = parameters.reserved_expressions.items()

if label is None:
label = criterion.content_string()

inputs = criterion.children
_, _, lhs, rhs = parse_criterion_sides(criterion, parameters)

stardard_forms = [(key, expr["standard"]["value"]*expr["standard"]["unit"]) for (key, expr) in reserved_expressions]
stardard_forms = [(key, expr.standard_value*expr.standard_unit) for (key, expr) in reserved_expressions]
compare = comparison_function(comparison_operator)

def compare_evaluate(_response: Any) -> ReachedCriteria:
return outcome_tag(label, compare(lhs, rhs, stardard_forms) is True)

graph.add_evaluation_node(
graph, END = outcome_graph(
label,
summary=f"Is {inputs[0]} {comparison_operator} {inputs[1]}?",
details=f"checks if {inputs[0]} {comparison_operator} {inputs[1]}.",
Expand Down Expand Up @@ -59,19 +57,19 @@ def compare_evaluate(_response: Any) -> ReachedCriteria:
return graph


def greater_than_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph:
def greater_than_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph:
return comparison_base_graph(criterion, parameters, comparison_operator=">", label=label)


def greater_than_or_equal_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph:
def greater_than_or_equal_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph:
# TODO: Add nodes for the equal case
return comparison_base_graph(criterion, parameters, comparison_operator=">=", label=label)


def less_than_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph:
def less_than_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph:
return comparison_base_graph(criterion, parameters, comparison_operator="<", label=label)


def less_than_or_equal_node(criterion: ExprNode, parameters: Parameters, label: str | None = None) -> CriteriaGraph:
def less_than_or_equal_node(criterion: ExprNode, parameters: EvaluationParameters, label: str | None = None) -> CriteriaGraph:
# TODO: Add nodes for the equal case
return comparison_base_graph(criterion, parameters, comparison_operator="<=", label=label)
Loading
Loading