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