From ee6d45882ee85da8da67386a64510bff2553a3ee Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 7 Aug 2026 13:09:10 +0100 Subject: [PATCH] Added support for significant figures (`sig_figs`) parameter in evaluation and documentation --- app/context/physical_quantity.py | 55 +++++++--- app/context/symbolic.py | 26 +++++ app/docs/dev.md | 12 ++ app/docs/user.md | 12 +- app/evaluation.py | 12 ++ app/tests/expression_utilities_test.py | 103 ++++++++++++++++++ .../physical_quantity_evaluation_test.py | 42 +++++++ app/tests/symbolic_evaluation_test.py | 47 ++++++++ app/utility/expression_utilities.py | 76 +++++++++++++ 9 files changed, 367 insertions(+), 18 deletions(-) diff --git a/app/context/physical_quantity.py b/app/context/physical_quantity.py index 28348de..1759d48 100644 --- a/app/context/physical_quantity.py +++ b/app/context/physical_quantity.py @@ -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, @@ -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()) @@ -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) diff --git a/app/context/symbolic.py b/app/context/symbolic.py index 960c989..4ea55b4 100644 --- a/app/context/symbolic.py +++ b/app/context/symbolic.py @@ -7,6 +7,7 @@ parse_expression, create_sympy_parsing_params, preprocess_expression, + sig_figs_match, ) from ..preview_implementations.symbolic_preview import preview_function @@ -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"}: + 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): diff --git a/app/docs/dev.md b/app/docs/dev.md index 7ada921..46eda49 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -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 diff --git a/app/docs/user.md b/app/docs/user.md index 83c929b..2e1dd43 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -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. @@ -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. diff --git a/app/evaluation.py b/app/evaluation.py index 4f86527..a227177 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -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 @@ -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",{}), } ) diff --git a/app/tests/expression_utilities_test.py b/app/tests/expression_utilities_test.py index 4476820..e5d93ed 100644 --- a/app/tests/expression_utilities_test.py +++ b/app/tests/expression_utilities_test.py @@ -5,6 +5,7 @@ compute_relative_tolerance_from_significant_decimals, convert_absolute_notation, convert_unicode_dashes, + count_sig_figs, create_expression_set, extract_latex, find_matching_parenthesis, @@ -12,6 +13,9 @@ 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, @@ -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): diff --git a/app/tests/physical_quantity_evaluation_test.py b/app/tests/physical_quantity_evaluation_test.py index 21516b2..2417bd5 100644 --- a/app/tests/physical_quantity_evaluation_test.py +++ b/app/tests/physical_quantity_evaluation_test.py @@ -417,5 +417,47 @@ def test_greek_letter_units(self, ans, res): assert result["is_correct"] is True +class TestSigFigs: + + base_params = { + "strict_syntax": False, + "physical_quantity": True, + "units_string": "SI", + "strictness": "natural", + } + + @pytest.mark.parametrize( + "description,response,answer,sig_figs,outcome", + [ + ("Correct value and precision, with units", "92.00 m", "92 m", 4, True), + ("Numerically equal but wrong precision", "92.00 m", "92 m", 2, False), + ("Wrong value", "91.00 m", "92 m", 4, False), + ("Unit mismatch fails regardless of sig figs", "92 s", "92 m", 2, False), + ("No units, plain numeric", "3.14", "3.14159", 3, True), + ("Non-numeric response", "two m", "92 m", 2, False), + ] + ) + def test_sig_figs(self, description, response, answer, sig_figs, outcome): + params = dict(self.base_params, sig_figs=sig_figs) + result = evaluation_function(response, answer, params) + assert result["is_correct"] is outcome + + def test_significant_figures_alias(self): + params = dict(self.base_params, significant_figures=4) + result = evaluation_function("92.00 m", "92 m", params) + assert result["is_correct"] is True + + def test_sig_figs_mutually_exclusive_with_atol(self): + params = dict(self.base_params, sig_figs=4, atol=0.1) + with pytest.raises(Exception): + evaluation_function("92.00 m", "92 m", params) + + @pytest.mark.parametrize("sig_figs", [0, -1, 3.5, True]) + def test_sig_figs_invalid_value_raises(self, sig_figs): + params = dict(self.base_params, sig_figs=sig_figs) + with pytest.raises(Exception): + evaluation_function("92.00 m", "92 m", params) + + if __name__ == "__main__": pytest.main(['-xk not slow', "--no-header", os.path.abspath(__file__)]) diff --git a/app/tests/symbolic_evaluation_test.py b/app/tests/symbolic_evaluation_test.py index 7ada94b..f17c470 100644 --- a/app/tests/symbolic_evaluation_test.py +++ b/app/tests/symbolic_evaluation_test.py @@ -2205,5 +2205,52 @@ def test_greek_letters_in_expressions(self, unicode_expr, letter_expr): assert result["is_correct"] is True +class TestSigFigs: + + @pytest.mark.parametrize( + "description,response,answer,sig_figs,outcome", + [ + ("Correct value and precision", "3.14", "3.14159", 3, True), + ("Wrong value", "3.15", "3.14159", 3, False), + ("Numerically equal but too many digits written", "3.14159", "3.14159", 3, False), + ("Numerically equal but too few digits written", "3.1", "3.10", 3, False), + ("Negative numbers", "-3.14", "-3.14159", 3, True), + ("Zero answer bypasses precision check", "0", "0", 3, True), + ("Trailing decimal zeros are significant", "92.00", "92", 4, True), + ("Leading zeros are not significant", "0.0032", "0.0032", 2, True), + ("Whole number trailing zeros are not significant", "540", "540", 2, True), + ("Whole number, wrong sig fig count", "540", "540", 3, False), + ("Scientific notation", "5.02e4", "50200", 3, True), + ("Non-numeric response", "two", "3.14159", 3, False), + ("Response with a symbol is rejected", "3.14*x", "3.14159", 3, False), + ] + ) + def test_sig_figs(self, description, response, answer, sig_figs, outcome): + params = {"strict_syntax": False, "sig_figs": sig_figs} + result = evaluation_function(response, answer, params) + assert result["is_correct"] is outcome + + def test_significant_figures_alias(self): + params = {"strict_syntax": False, "significant_figures": 3} + result = evaluation_function("3.14", "3.14159", params) + assert result["is_correct"] is True + + def test_sig_figs_mutually_exclusive_with_atol(self): + params = {"strict_syntax": False, "sig_figs": 3, "atol": 0.1} + with pytest.raises(Exception): + evaluation_function("3.14", "3.14159", params) + + def test_sig_figs_mutually_exclusive_with_rtol(self): + params = {"strict_syntax": False, "significant_figures": 3, "relative_tolerance": 0.1} + with pytest.raises(Exception): + evaluation_function("3.14", "3.14159", params) + + @pytest.mark.parametrize("sig_figs", [0, -1, 3.5, True]) + def test_sig_figs_invalid_value_raises(self, sig_figs): + params = {"strict_syntax": False, "sig_figs": sig_figs} + with pytest.raises(Exception): + evaluation_function("3.14", "3.14159", params) + + if __name__ == "__main__": pytest.main(['-xk not slow', "--tb=line", '--durations=10', os.path.abspath(__file__)]) diff --git a/app/utility/expression_utilities.py b/app/utility/expression_utilities.py index 0cb688a..f739cce 100644 --- a/app/utility/expression_utilities.py +++ b/app/utility/expression_utilities.py @@ -28,6 +28,7 @@ from sympy.printing.latex import LatexPrinter from sympy import Basic, Symbol, Equality, Function +import math import re from typing import Dict, List, TypedDict @@ -576,6 +577,81 @@ def compute_relative_tolerance_from_significant_decimals(string): return rtol +def round_to_sig_figs(value, sig_figs): + if value == 0: + return 0.0 + return float(f"{value:.{sig_figs}g}") + + +def split_numeric_string(value): + ''' + Input: + value : a string that may represent a plain number (optionally signed, + with an optional decimal point and/or exponent) + Output: + (int_part, frac_part, has_decimal) if value parses as a plain number, + None otherwise. + ''' + if not isinstance(value, str): + return None + stripped = value.strip() + body = stripped[1:] if stripped[:1] in ("+", "-") else stripped + + mantissa, sep, exponent = body.partition("e") if "e" in body else body.partition("E") + if sep and not exponent.lstrip("+-").isdigit(): + return None + + has_decimal = "." in mantissa + int_part, _, frac_part = mantissa.partition(".") + if not (int_part.isdigit() or frac_part.isdigit()): + return None + if int_part and not int_part.isdigit(): + return None + if frac_part and not frac_part.isdigit(): + return None + + return int_part, frac_part, has_decimal + + +def count_sig_figs(int_part, frac_part, has_decimal): + digits = int_part + frac_part + first_nonzero = next((i for i, d in enumerate(digits) if d != "0"), None) + if first_nonzero is None: + return 1 + + trimmed = digits[first_nonzero:] + if has_decimal: + return len(trimmed) + return len(trimmed.rstrip("0")) or 1 + + +def sig_figs_match(response_string, response_value, answer_value, sig_figs): + ''' + Input: + response_string : raw string as written by the learner, used to count + significant figures as written (a parsed float loses + trailing zeros and decimal-point placement) + response_value : response_string parsed to a float + answer_value : answer parsed to a float + sig_figs : required number of significant figures (positive int) + Output: + True if response_string parses as a plain number, its value rounds to + the same value as the answer to sig_figs, and it was written to exactly + sig_figs significant figures. False otherwise. + ''' + parts = split_numeric_string(response_string) + if parts is None: + return False + + rounded_answer = round_to_sig_figs(answer_value, sig_figs) + rounded_response = round_to_sig_figs(response_value, sig_figs) + numeric_correct = abs(rounded_response - rounded_answer) <= math.ulp(abs(rounded_answer)) + + precision_correct = response_value == 0 or count_sig_figs(*parts) == sig_figs + + return numeric_correct and precision_correct + + # -------- (Sympy) Expression Parsing Utilities class SymbolData(TypedDict): latex: str