Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions app/context/physical_quantity/graphs/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
is_proportional,
parse_criterion_sides,
)
from app.context.symbolic.numerical import matches_written_precision
from app.context.tolerance import values_within_tolerance
from app.evaluation_parameters import EvaluationParameters
from app.feedback.physical_quantity import feedback_string_generators as physical_quantity_feedback_string_generators
Expand All @@ -25,10 +26,14 @@


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, atol)`, with `rtol` taken from the significant figures of the answer if no tolerance is given
and the response isn't checked for its written precision (`sig_figs`/`dp`).
"""
rtol = parameters.parsing_parameters["rtol"]
atol = parameters.parsing_parameters["atol"]
if parameters.atol == 0 and parameters.rtol == 0:
uses_precision = parameters.sig_figs is not None or parameters.dp is not None
if parameters.atol == 0 and parameters.rtol == 0 and not uses_precision:
ans = parameters.reserved_expressions["answer"].quantity.value
if ans is not None:
rtol = compute_relative_tolerance_from_significant_decimals(ans.content_string())
Expand Down Expand Up @@ -82,18 +87,42 @@ def _within_tolerance(parameters: EvaluationParameters, lhs_string: str, rhs_str
return values_within_tolerance(res, ans, rtol, atol * answer_unit_factor)


def _matches_written_precision(parameters: EvaluationParameters) -> bool:
"""
Whether the value of the response, as written, is the value of the answer to `sig_figs`/`dp`.
The values are compared in SI base units, so `dp` assumes the response has the unit of the answer.
"""
response_value = parameters.reserved_expressions["response"].quantity.value
if response_value is None:
return False
return matches_written_precision(
parameters.reserved_expressions["response"].standard_value,
parameters.reserved_expressions["answer"].standard_value,
response_value.content_string(),
parameters,
)


def _quantity_match(parameters: EvaluationParameters, label: str, sides: CriterionSides, rtol: float, atol: float) -> Evaluate:
quantities = sides.quantities.items()
is_response_answer_criterion = {sides.lhs_string, sides.rhs_string} == {"response", "answer"}
uses_precision = parameters.sig_figs is not None or parameters.dp is not None

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

substitutions = [(key, expr.standard_value) for (key, expr) in quantities]
value_match = is_equal(sides.lhs, sides.rhs, substitutions)
if value_match is False:
value_match = _within_tolerance(parameters, sides.lhs_string, sides.rhs_string, rtol, atol)
# sig_figs/dp replace the value comparison rather than being a fallback,
# since a value equal to the answer but written to the wrong precision is incorrect
value_match: bool | None
if uses_precision and is_response_answer_criterion:
value_match = _matches_written_precision(parameters)
else:
substitutions = [(key, expr.standard_value) for (key, expr) in quantities]
value_match = is_equal(sides.lhs, sides.rhs, substitutions)
if value_match is False:
value_match = _within_tolerance(parameters, sides.lhs_string, sides.rhs_string, rtol, atol)

substitutions = [(key, expr.standard_unit) for (key, expr) in quantities]
unit_match = is_equal(sides.lhs, sides.rhs, substitutions)
Expand Down
25 changes: 24 additions & 1 deletion app/context/symbolic/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from lambdafeedback.slr_parsing import ExprNode

from app.context.operators import COMPARISON_OPERATORS
from app.context.symbolic.numerical import compare_numerically
from app.context.symbolic.numerical import compare_numerically, matches_written_precision
from app.evaluation_parameters import EvaluationParameters
from app.types import SubstitutionList, Substitutions

Expand Down Expand Up @@ -105,10 +105,33 @@ def is_inequality_or_boolean(expr: object) -> bool:
return isinstance(expr, (Relational, BooleanAtom)) and not isinstance(expr, Equality)


def uses_written_precision(criterion: ExprNode, parameters: EvaluationParameters) -> bool:
"""Whether `sig_figs`/`dp` is set and `criterion` compares the response and the answer directly."""
if parameters.sig_figs is None and parameters.dp is None:
return False
lhs_string = criterion.children[0].content_string().strip()
rhs_string = criterion.children[1].content_string().strip()
return {lhs_string, rhs_string} == {"response", "answer"}


def check_written_precision(criterion: ExprNode, lhs_expr: Basic, rhs_expr: Basic, parameters: EvaluationParameters) -> bool:
"""Whether the response, as written, is the answer to `sig_figs`/`dp`, for a response/answer `criterion`."""
response_string = parameters.reserved_expressions_strings["learner"]["response"]
if criterion.children[0].content_string().strip() == "response":
return matches_written_precision(lhs_expr, rhs_expr, response_string, parameters)
return matches_written_precision(rhs_expr, lhs_expr, response_string, parameters)


def check_equality(
criterion: ExprNode, parameters: EvaluationParameters, local_substitutions: Substitutions = ()
) -> bool | None:
lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters, local_substitutions)

# sig_figs/dp replace the comparison rather than being a fallback,
# since a response equal to the answer but written to the wrong precision is incorrect
if uses_written_precision(criterion, parameters):
return check_written_precision(criterion, lhs_expr, rhs_expr, parameters)

if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality):
return False
elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality):
Expand Down
90 changes: 81 additions & 9 deletions app/context/symbolic/numerical.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,91 @@
from __future__ import annotations

import math

from sympy import Basic, pi, N

from lambdafeedback.expression_parsing import read_written_number, round_to_decimal_places, round_to_significant_figures

from app.context.tolerance import values_within_tolerance
from app.evaluation_parameters import EvaluationParameters


# REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols.
# Replacing it by name fixes the issue but a more robust solution should be found for cases where there
# are other reserved symbols.
def replace_pi(expr: Basic) -> Basic:
pi_symbol = pi
for s in expr.free_symbols:
if str(s) == 'pi':
pi_symbol = s
return expr.subs(pi_symbol, float(pi))


def _rounded_values_equal(rounded_response: float, rounded_answer: float) -> bool:
# Allows for the last bit of the rounded float, e.g. when 0.1 + 0.2 is rounded
return abs(rounded_response - rounded_answer) <= math.ulp(abs(rounded_answer))


def sig_figs_match(response_string: str, response_value: float, answer_value: float, sig_figs: int) -> bool:
"""
Whether `response_string`, as written, is the answer to exactly `sig_figs` significant figures:
it is a plain number, its value rounds to the same value as the answer, and it is written to `sig_figs`
significant figures (`response_value`, a float, has lost the trailing zeros that count).
A zero response is not checked for precision.
"""
written = read_written_number(response_string)
if written is None:
return False

rounded_answer = round_to_significant_figures(answer_value, sig_figs)
rounded_response = round_to_significant_figures(response_value, sig_figs)
numeric_correct = _rounded_values_equal(rounded_response, rounded_answer)

precision_correct = response_value == 0 or written.significant_figures == sig_figs

return numeric_correct and precision_correct


def decimal_places_match(response_string: str, response_value: float, answer_value: float, decimal_places: int) -> bool:
"""
Whether `response_string`, as written, is the answer to exactly `decimal_places` decimal places:
it is a plain number, its value rounds to the same value as the answer, and it is written to
`decimal_places` decimal places.
"""
written = read_written_number(response_string)
if written is None:
return False

rounded_answer = float(round_to_decimal_places(answer_value, decimal_places))
rounded_response = float(round_to_decimal_places(response_value, decimal_places))
numeric_correct = _rounded_values_equal(rounded_response, rounded_answer)

# Unlike sig_figs_match, a zero response is checked too: "0" has 0 decimal places and "0.00" has 2
precision_correct = written.decimal_places == decimal_places

return numeric_correct and precision_correct


def matches_written_precision(
response_expr: Basic, answer_expr: Basic, response_string: str, parameters: EvaluationParameters
) -> bool:
"""
Whether the response, written as `response_string`, is the answer to the `sig_figs` significant figures
or `dp` decimal places in `parameters` (one of them is set). False if either side is not a number.
"""
try:
response_value = float(N(replace_pi(response_expr)))
answer_value = float(N(replace_pi(answer_expr)))
except TypeError:
return False

if parameters.sig_figs is not None:
return sig_figs_match(response_string, response_value, answer_value, parameters.sig_figs)
elif parameters.dp is not None:
return decimal_places_match(response_string, response_value, answer_value, parameters.dp)
return False


def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters: EvaluationParameters) -> bool:
"""
Compares `lhs_expr` and `rhs_expr` numerically using the `atol` and `rtol` parameters.
Expand All @@ -17,15 +98,6 @@ def compare_numerically(lhs_expr: Basic, rhs_expr: Basic, parameters: Evaluation
or float(parameters.atol) > 0):
return False

# REMARK: 'pi' should be a reserved symbol but it is sometimes not treated as one, possibly because of input symbols.
# The two lines below this comments fixes the issue but a more robust solution should be found for cases where there
# are other reserved symbols.
def replace_pi(expr: Basic) -> Basic:
pi_symbol = pi
for s in expr.free_symbols:
if str(s) == 'pi':
pi_symbol = s
return expr.subs(pi_symbol, float(pi))
# NOTE: This code assumes that the left hand side is the response and the right hand side is the answer
# Separates LHS and RHS, parses and evaluates them
res = N(replace_pi(lhs_expr))
Expand Down
10 changes: 10 additions & 0 deletions app/context/tolerance.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,13 @@ def values_within_tolerance(res: Basic, ans: Basic, rtol: float, atol: float) ->
return bool(abs(float((ans - res) / ans)) <= rtol)
except (TypeError, ZeroDivisionError):
return False


def relative_tolerance_from_sig_figs(sig_figs: int) -> float:
"""The relative tolerance for agreement to `sig_figs` significant figures, 5*10**(-sig_figs)."""
return 5 * 10 ** (-sig_figs)


def absolute_tolerance_from_decimal_places(decimal_places: int) -> float:
"""The absolute tolerance for agreement to `decimal_places` decimal places, 0.5*10**(-decimal_places)."""
return 0.5 * 10 ** (-decimal_places)
12 changes: 12 additions & 0 deletions app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,18 @@ along the the following base tokens:

**TODO** Describe shared default parameters

##### Significant figures and decimal places

The `significant_figures`/`sig_figs`, `significant_figures_tolerance`/`sig_figs_tol`, `decimal_places`/`dp` and `decimal_places_tolerance`/`dp_tol` parameters (see the user docs) are read in `_build_parameters` (`app/evaluation.py`): `_apply_precision_aliases` copies the long names to the short ones and `_apply_precision_parameters` checks them. At most one of the four, or of `atol`/`rtol`, can be given: a conflict, or a count below the minimum (1 for `sig_figs`/`sig_figs_tol`, 0 for `dp`/`dp_tol`), raises a `ValueError`, and a count that isn't an `int` (a `bool` isn't one) raises a `TypeError`.

The tolerance forms need no code in the contexts: `sig_figs_tol` sets `rtol` to `relative_tolerance_from_sig_figs(n)` (`5*10**(-n)`) and `dp_tol` sets `atol` to `absolute_tolerance_from_decimal_places(n)` (`0.5*10**(-n)`), both in `app/context/tolerance.py`, before the parsing parameters are made, so the usual `atol`/`rtol` handling applies. Unlike `compute_relative_tolerance_from_significant_decimals` there is no `DEFAULT_SIGNIFICANT_FIGURES` floor.

`sig_figs` and `dp` are passed on as `EvaluationParameters.sig_figs`/`dp`. They check the precision the response is *written* to, so they need the response as a string: a parsed float loses trailing zeros (`92`, `92.0` and `92.00` are equal once parsed). `matches_written_precision` (`app/context/symbolic/numerical.py`) evaluates both sides numerically and calls `sig_figs_match` or `decimal_places_match`, which read the string with `read_written_number` from `expression_parsing`, compare the values rounded with `round_to_significant_figures`/`round_to_decimal_places` (within `math.ulp` of the rounded answer) and compare the written precision. `sig_figs_match` doesn't check the precision of a zero response, since the significant figures of zero aren't well defined; `decimal_places_match` does (`0` has 0 decimal places and `0.00` has 2).

Both contexts only use them for a criterion that compares `response` and `answer` directly, and there they replace the comparison rather than being a fallback, since a response equal to the answer but written to the wrong precision is incorrect:
- `symbolic`: at the top of `check_equality` (`app/context/symbolic/comparison.py`), with the preprocessed response string from `reserved_expressions_strings`.
- `physical_quantity`: in place of the value comparison in `_quantity_match` (`app/context/physical_quantity/graphs/match.py`), with the value as written in the response and the SI standard values; the units are compared as usual. `_tolerances` skips the tolerance taken from the answer's significant figures when either is set.

## Feedback and tag generation

- Generate feedback procedures from criteria, each procedure return a boolean that indicates whether the corresponding criterion is satisfied or not, a string intended to be shown to the student, and a list of tags indicating what was found when checking the criteria
Expand Down
Loading
Loading