Skip to content
63 changes: 45 additions & 18 deletions app/context/physical_quantity.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
substitute_input_symbols,
create_sympy_parsing_params,
compute_relative_tolerance_from_significant_decimals,
parse_expression
parse_expression,
sig_figs_match,
decimal_places_match,
)
from ..utility.physical_quantity_utilities import (
units_sets_dictionary,
Expand Down Expand Up @@ -218,7 +220,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 and parameters.get('dp') 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,22 +273,47 @@ 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):
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)
sig_figs = parameters.get('sig_figs')
dp = parameters.get('dp')
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 or dp is not None) and is_plain_response_answer_criterion:
# sig_figs / dp fully replace 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. sig_figs and dp are mutually exclusive (enforced in
# evaluation.py).
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:
if sig_figs is not None:
value_match = sig_figs_match(response_string, float(res_value), float(ans_value), sig_figs)
else:
value_match = decimal_places_match(response_string, float(res_value), float(ans_value), dp)
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):
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)

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

from ..preview_implementations.symbolic_preview import preview_function
Expand Down Expand Up @@ -117,6 +119,35 @@ 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")
dp = parameters_dict.get("dp")
if sig_figs is not None or dp is not None:
# sig_figs / dp are only meaningful for a direct response/answer numeric comparison (not
# arbitrary custom criteria), and they fully replace 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". sig_figs and dp are mutually exclusive (enforced in evaluation.py).
lhs_string = criterion.children[0].content_string().strip()
rhs_string = criterion.children[1].content_string().strip()
if {lhs_string, rhs_string} == {"response", "answer"}:
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:
if sig_figs is not None:
return sig_figs_match(response_string, float(response_value), float(answer_value), sig_figs)
return decimal_places_match(response_string, float(response_value), float(answer_value), dp)
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
26 changes: 26 additions & 0 deletions app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,32 @@ 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.

##### Significant figures as tolerance (`sig_figs_tol`)

The `significant_figures_tolerance`/`sig_figs_tol` parameter is the *fuzzy* counterpart to `sig_figs`: instead of checking written precision, it accepts any response that agrees with the answer to `n` significant figures. It is implemented entirely in `evaluation_function` (`evaluation.py`), before context determination: `relative_tolerance_from_sig_figs(n)` (`app/utility/expression_utilities.py`) returns `5*10**(-n)` and this is written into `params["rtol"]`, so the feature needs no context-specific code — it reuses the existing `rtol` handling in `check_equality` (`context/symbolic.py`) and in `quantity_match`/`comparison_base_graph` (`context/physical_quantity.py`), including the fact that the implicit `compute_relative_tolerance_from_significant_decimals` derivation is skipped whenever `rtol` is non-zero. Unlike that implicit derivation, `relative_tolerance_from_sig_figs` applies no `DEFAULT_SIGNIFICANT_FIGURES` floor — the explicit count is used as given. `sig_figs_tol` is mutually exclusive with `sig_figs`/`significant_figures` and with `atol`/`rtol` (enforced with a raised `Exception` in `evaluation_function`).

##### Decimal places (`dp`)

The `decimal_places`/`dp` parameter is the decimal-place analogue of `sig_figs` and is built on the same helpers. `app/utility/expression_utilities.py` adds `round_to_decimal_places`, `count_decimal_places` and `decimal_places_match`; the last mirrors `sig_figs_match` (same `split_numeric_string` gate, same `math.ulp`-of-the-rounded-answer numeric check) but compares written *decimal places* rather than significant figures. Unlike `round_to_sig_figs`, `round_to_decimal_places` returns a *string* (e.g. `"0.00"`, not the float `0.0`) rather than a float, because a decimal-place count is meaningless once collapsed to a float — trailing zeros would be lost; `decimal_places_match` casts it back to `float` only for the numeric comparison. `count_decimal_places` takes the *raw string* rather than parsed digit parts (as `count_sig_figs` does), because a scientific-notation exponent has to be subtracted from the fractional digit count to get the effective written precision (`5.02e4` → 0, `5e-3` → 3). `dp` may be `0`. Unlike `sig_figs_match`, `decimal_places_match` has no bypass for a zero-valued response: a decimal-place count is well-defined even at zero (`"0"` is 0 decimal places, `"0.00"` is 2), whereas `count_sig_figs` collapses any all-zero digit string to a single significant figure regardless of how it was written, which is why `sig_figs_match` bypasses the precision check there.

`dp` is threaded from `params` into `evaluation_parameters` alongside `sig_figs` in `evaluation.py` and integrates into both contexts at exactly the same points as `sig_figs` — the `check_equality` pre-check (`context/symbolic.py`) and the `quantity_match` closure (`context/physical_quantity.py`) — sharing the `sig_figs` branch there (`sig_figs` and `dp` are mutually exclusive, enforced in `evaluation_function`). The same restriction to a direct `response = answer` comparison applies, and the implicit `compute_relative_tolerance_from_significant_decimals` derivation is skipped whenever `dp` is set.

##### Decimal places as tolerance (`dp_tol`)

The `decimal_places_tolerance`/`dp_tol` parameter is the *fuzzy* counterpart to `dp`, analogous to `sig_figs_tol` but yielding an *absolute* tolerance (a decimal-place count is scale-dependent). It is implemented entirely in `evaluation_function` (`evaluation.py`): `absolute_tolerance_from_decimal_places(n)` (`app/utility/expression_utilities.py`) returns `0.5*10**(-n)` and this is written into `params["atol"]`, reusing all existing `atol` handling with no context-specific code. `dp_tol` is mutually exclusive with `dp`/`decimal_places`, `sig_figs`/`significant_figures`, `sig_figs_tol`/`significant_figures_tolerance` and `atol`/`rtol` (enforced with a raised `Exception` in `evaluation_function`).

## 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