Skip to content
Open
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
55 changes: 38 additions & 17 deletions app/context/physical_quantity.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
substitute_input_symbols,
create_sympy_parsing_params,
compute_relative_tolerance_from_significant_decimals,
parse_expression
parse_expression,
sig_figs_match,
)
from ..utility.physical_quantity_utilities import (
units_sets_dictionary,
Expand Down Expand Up @@ -218,7 +219,7 @@ def criterion_match_node(criterion, parameters, label=None):
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:
if parameters.get('atol', 0) == 0 and parameters.get('rtol', 0) == 0 and parameters.get('sig_figs') is None:
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 @@ -271,21 +272,41 @@ def quantity_match(unused_inputs):
if res_unit is not None and ans_unit is None:
return {label+"_UNEXPECTED_UNIT": {"lhs": lhs_string, "rhs": rhs_string}}

substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions]
value_match = is_equal(lhs, rhs, substitutions)

if value_match is False:
# TODO: better analysis of where `answer` is found in the criteria so that
# numerical tolerances can be applied appropriately
if parsing_params.get('rtol', 0) > 0 or parsing_params.get('atol', 0) > 0:
if (lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer'):
ans = parameters["reserved_expressions"]["answer"]["standard"]["value"].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):
value_match = bool(abs(float(ans-res)) < parsing_params['atol'])
sig_figs = parameters.get('sig_figs')
is_plain_response_answer_criterion = (
(lhs_string == 'answer' and rhs_string == 'response') or (lhs_string == 'response' and rhs_string == 'answer')
)
if sig_figs is not None and is_plain_response_answer_criterion:
# sig_figs fully replaces the ordinary value match below rather than falling back
# from it — a value that's numerically equal but written to the wrong precision must
# still fail, so this can't be gated behind "ordinary value match already returned False".
# It requires the response's raw written value string (a parsed float loses trailing
# zeros), fetched the same way the implicit-tolerance feature above fetches the answer's.
# Numeric correctness is still checked on the standardised (SI) values, consistent with
# how matches/atol/rtol behave.
response_string = parameters["reserved_expressions"]["response"]["quantity"].value.content_string()
ans_value = parameters["reserved_expressions"]["answer"]["standard"]["value"].simplify()
res_value = parameters["reserved_expressions"]["response"]["standard"]["value"].simplify()
try:
value_match = sig_figs_match(response_string, float(res_value), float(ans_value), sig_figs)
except TypeError:
value_match = False
else:
substitutions = [(key, expr["standard"]["value"]) for (key, expr) in reserved_expressions]
value_match = is_equal(lhs, rhs, substitutions)

if value_match is False:
# TODO: better analysis of where `answer` is found in the criteria so that
# numerical tolerances can be applied appropriately
if parsing_params.get('rtol', 0) > 0 or parsing_params.get('atol', 0) > 0:
if is_plain_response_answer_criterion:
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):
value_match = bool(abs(float(ans-res)) < parsing_params['atol'])

substitutions = [(key, expr["standard"]["unit"]) for (key, expr) in reserved_expressions]
unit_match = is_equal(lhs, rhs, substitutions)
Expand Down
26 changes: 26 additions & 0 deletions app/context/symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
parse_expression,
create_sympy_parsing_params,
preprocess_expression,
sig_figs_match,
)

from ..preview_implementations.symbolic_preview import preview_function
Expand Down Expand Up @@ -117,6 +118,31 @@ def do_comparison(comparison_symbol, expression):

def check_equality(criterion, parameters_dict, local_substitutions=[]):
lhs_expr, rhs_expr = create_expressions_for_comparison(criterion, parameters_dict, local_substitutions)

sig_figs = parameters_dict.get("sig_figs")
if sig_figs is not None:
# sig_figs is only meaningful for a direct response/answer numeric comparison (not arbitrary
# custom criteria), and it fully replaces the ordinary equality logic below rather than
# falling back to it — a value that's numerically equal but written to the wrong precision
# must still fail, so this can't be gated behind "ordinary equality already returned False".
lhs_string = criterion.children[0].content_string().strip()
rhs_string = criterion.children[1].content_string().strip()
if {lhs_string, rhs_string} == {"response", "answer"}:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this different to the test in the physical_quantity context line 277, which permitted the opposite variant too (both of response==answer and answer==response)?

def replace_pi(expr):
pi_symbol = pi
for s in expr.free_symbols:
if str(s) == 'pi':
pi_symbol = s
return expr.subs(pi_symbol, float(pi))
res = N(replace_pi(lhs_expr))
ans = N(replace_pi(rhs_expr))
response_value, answer_value = (res, ans) if lhs_string == "response" else (ans, res)
response_string = parameters_dict["reserved_expressions_strings"]["learner"]["response"]
try:
return sig_figs_match(response_string, float(response_value), float(answer_value), sig_figs)
except TypeError:
return False

if isinstance(lhs_expr, Equality) and not isinstance(rhs_expr, Equality):
result = False
elif not isinstance(lhs_expr, Equality) and isinstance(rhs_expr, Equality):
Expand Down
12 changes: 12 additions & 0 deletions app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ along the the following base tokens:

**TODO** Describe shared default parameters

##### Significant figures (`sig_figs`)

`app/utility/expression_utilities.py` provides `round_to_sig_figs`, `split_numeric_string`, `count_sig_figs` and `sig_figs_match`, which implement the `significant_figures`/`sig_figs` parameter (see the user docs). `sig_figs_match(response_string, response_value, answer_value, sig_figs)` is pure (no SymPy dependency) and returns a single bool: `response_string` must parse as a plain number (via `split_numeric_string`), its value must round to the same value as the answer to `sig_figs` (via `round_to_sig_figs`, compared within `math.ulp` of the rounded answer), and it must have been written to exactly `sig_figs` significant figures (via `count_sig_figs`, applied to the parsed integer/fractional digit parts).

`response_string` must be the response's *raw* as-written string, not a parsed/simplified expression, since a parsed float loses trailing zeros and decimal-point placement (`92.0 == 92.00 == 92` once parsed, but they have different significant-figure counts as written).

`sig_figs` is threaded from `params` into `evaluation_parameters` alongside `atol`/`rtol` in `evaluation.py`, and is mutually exclusive with them (enforced with a raised `Exception` in `evaluation_function`, before context determination). It integrates into each context exactly the way `atol`/`rtol` already do — by changing the boolean result inside the *existing* evaluate closure — rather than by adding new criterion-graph tags or branches:
- `symbolic`: at the top of `check_equality` (`context/symbolic.py`), before the ordinary equality logic, since a value that's numerically equal but written to the wrong precision must still fail — it can't be gated behind "ordinary equality already returned `False`" the way the `atol`/`rtol` fallback is.
- `physical_quantity`: inside the `quantity_match` closure in `criterion_match_node` (`context/physical_quantity.py`), replacing the ordinary `is_equal`-based value match for the same reason. Unit matching is unaffected — `sig_figs` only changes how the *value* half of `matches` is decided. Note that `quantity_match` reads `atol`/`rtol` off a local `parsing_params` closure variable that can also be silently populated by the existing implicit-tolerance-from-significant-decimals feature (see `compute_relative_tolerance_from_significant_decimals`) when neither is set explicitly; that implicit derivation is skipped whenever `sig_figs` is set, so the two features stay independent.

Both integration points restrict `sig_figs` to a direct `response = answer` (or `answer = response`) comparison — it has no effect on other custom criteria.

## 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
12 changes: 11 additions & 1 deletion app/docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Note that this function is designed to handle comparisons of mathematical expres

### Optional parameters

There are 15 optional parameters that can be set: `absolute_tolerance`, `complexNumbers`, `convention`, `criteria`, `elementary_functions`, `feedback_for_incorrect_response`, `multiple_answers_criteria`, `physical_quantity`, `plus_minus`/`minus_plus`, `rtol`, `specialFunctions`, `strict_syntax`, `strictness`, `symbol_assumptions`.
There are 16 optional parameters that can be set: `absolute_tolerance`, `complexNumbers`, `convention`, `criteria`, `elementary_functions`, `feedback_for_incorrect_response`, `multiple_answers_criteria`, `physical_quantity`, `plus_minus`/`minus_plus`, `rtol`, `significant_figures`, `specialFunctions`, `strict_syntax`, `strictness`, `symbol_assumptions`.

#### `absolute_tolerance` (`atol`)
Sets the absolute tolerance, $e_a$, i.e. if the answer, $x$, and response, $\tilde{x}$, are numerical values then the response is considered equal to the answer if $|x-\tilde{x}| \leq e_aBy default `absolute_tolerance` is set to `0`, which means the comparison will be done with as high accuracy as possible. If either the answer or the response aren't numerical expressions this parameter is ignored.
Expand Down Expand Up @@ -79,6 +79,16 @@ When `physical_quantity` the evaluation function will generate feedback based on
#### `relative_tolerance` (`rtol`)
Sets the relative tolerance, $e_r$, i.e. if the answer, $x$, and response, $\tilde{x}$, are numerical values then the response is considered equal to the answer if $\left|\frac{x-\tilde{x}}{x}\right| \leq e_r$. By default `relative_tolerance` is set to `0`, which means the comparison will be done with as high accuracy as possible. If either the answer or the response aren't numerical expressions this parameter is ignored.

#### `significant_figures` (`sig_figs`)

Checks the response against the answer to a fixed number of significant figures, both for numerical correctness and for the precision the response was actually *written* to. It only applies to a plain numeric response (or, when `physical_quantity` is `true`, a numeric value with units) being compared directly against the answer — it is ignored for any other kind of criterion.

For example, with an answer of `3.14159` and `significant_figures` set to `3`: the response `3.14` is accepted (correct value, written to 3 significant figures). `3.1` is rejected for having too few significant figures, and `3.14159` is rejected for having too many — even though both are numerically close to the answer.

Significant figures are counted as written: leading zeros are never significant (`0.0032` has 2), trailing zeros after a decimal point are always significant (`92.00` has 4), and trailing zeros in a whole number are only significant if a decimal point is explicitly written (`540` has 2, but `540.` has 3).

`significant_figures` cannot be combined with `atol`/`absolute_tolerance` or `rtol`/`relative_tolerance` — setting both will raise an error. Unlike those tolerance parameters, a `significant_figures` failure produces the same generic feedback as any other incorrect response; it does not distinguish "wrong value" from "wrong precision" from "not a number".

#### `strictness`

Controls the conventions used when parsing physical quantities.
Expand Down
12 changes: 12 additions & 0 deletions app/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,17 @@ def evaluation_function(response, answer, params, include_test_data=False) -> di
if "absolute_tolerance" in params:
params["atol"] = params["absolute_tolerance"]

if "significant_figures" in params:
params["sig_figs"] = params["significant_figures"]

if "sig_figs" in params:
uses_tolerance = any(k in params for k in ("relative_tolerance", "rtol", "absolute_tolerance", "atol"))
if uses_tolerance:
raise Exception("`sig_figs`/`significant_figures` cannot be used together with `atol`/`rtol`.")
sig_figs = params["sig_figs"]
if not isinstance(sig_figs, int) or isinstance(sig_figs, bool) or sig_figs < 1:
raise Exception("`sig_figs`/`significant_figures` must be a positive integer.")

evaluation_result = EvaluationResult()
evaluation_result.is_correct = False

Expand Down Expand Up @@ -335,6 +346,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),
"sig_figs": parameters.get("sig_figs"),
"custom_feedback": parameters.get("custom_feedback",{}),
}
)
Expand Down
103 changes: 103 additions & 0 deletions app/tests/expression_utilities_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
compute_relative_tolerance_from_significant_decimals,
convert_absolute_notation,
convert_unicode_dashes,
count_sig_figs,
create_expression_set,
extract_latex,
find_matching_parenthesis,
is_multiple_answers_wrapper,
latex_symbols,
preprocess_expression,
protect_elementary_functions_substitutions,
round_to_sig_figs,
sig_figs_match,
split_numeric_string,
substitute,
substitute_input_symbols,
substitutions_sort_key,
Expand Down Expand Up @@ -247,6 +251,105 @@ def test_relative_tolerance(self, string, expected):
assert result == pytest.approx(expected)


class TestRoundToSigFigs:

@pytest.mark.parametrize(
"value, sig_figs, expected",
[
(0, 3, 0.0),
(0.0, 3, 0.0),
(3.14159, 3, 3.14),
(3.14159, 6, 3.14159),
(540, 2, 540.0),
(0.0032, 2, 0.0032),
(50200, 3, 50200.0),
(-3.14159, 3, -3.14),
]
)
def test_round_to_sig_figs(self, value, sig_figs, expected):
assert round_to_sig_figs(value, sig_figs) == pytest.approx(expected)


class TestSplitNumericString:

@pytest.mark.parametrize(
"value, expected",
[
("92.00", ("92", "00", True)),
("92", ("92", "", False)),
("0.0032", ("0", "0032", True)),
("540", ("540", "", False)),
("540.", ("540", "", True)),
("-3.14", ("3", "14", True)),
("+3.14", ("3", "14", True)),
("5.02e4", ("5", "02", True)),
("0", ("0", "", False)),
(3.14, None),
("two", None),
("3.14e", None),
("", None),
("--3.14", None),
]
)
def test_split_numeric_string(self, value, expected):
assert split_numeric_string(value) == expected


class TestCountSigFigs:

@pytest.mark.parametrize(
"int_part, frac_part, has_decimal, expected",
[
("92", "00", True, 4),
("92", "", False, 2),
("0", "0032", True, 2),
("540", "", False, 2),
("540", "", True, 3),
("0", "", False, 1),
]
)
def test_count_sig_figs(self, int_part, frac_part, has_decimal, expected):
assert count_sig_figs(int_part, frac_part, has_decimal) == expected


class TestSigFigsMatch:

@pytest.mark.parametrize(
"response_string, response_value, answer_value, sig_figs, expected",
[
# Correct value and precision
("3.14", 3.14, 3.14159, 3, True),
# Numerically wrong
("3.15", 3.15, 3.14159, 3, False),
# Numerically correct but too many digits written
("3.14159", 3.14159, 3.14159, 3, False),
# Numerically correct but too few digits written
("3.1", 3.1, 3.10, 3, False),
# Negative numbers
("-3.14", -3.14, -3.14159, 3, True),
# Zero answer: precision check is bypassed
("0", 0.0, 0.0, 3, True),
# Trailing decimal zeros are significant
("92.00", 92.00, 92, 4, True),
# Leading zeros are not significant
("0.0032", 0.0032, 0.0032, 2, True),
# Whole number trailing zeros are not significant
("540", 540, 540, 2, True),
("540", 540, 540, 3, False),
# Explicit trailing decimal point makes trailing zeros significant
("540.", 540, 540, 3, True),
# Scientific notation
("5.02e4", 50200, 50200, 3, True),
# Non-numeric response
(3.14, 3.14, 3.14159, 3, False),
("two", 0, 3.14159, 3, False),
("3.14e", 3.14, 3.14159, 3, False),
]
)
def test_sig_figs_match(self, response_string, response_value, answer_value, sig_figs, expected):
assert sig_figs_match(response_string, response_value, answer_value, sig_figs) is expected


class TestSympySymbols:

def test_returns_symbol_objects(self):
Expand Down
Loading
Loading