From 62a2d6c697e79b54c94a8d4f2e9f0e11d9175da1 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 14:05:44 +0100 Subject: [PATCH 1/5] Added support for significant-figures comparison (`sig_figs`) with validation and mutual exclusivity checks --- app/docs/dev.md | 15 +++- app/docs/user.md | 16 +++- app/evaluation.py | 42 ++++++++-- app/evaluation_test.py | 172 +++++++++++++++++++++++++++++++++++++++++ readme.md | 2 +- 5 files changed, 238 insertions(+), 9 deletions(-) diff --git a/app/docs/dev.md b/app/docs/dev.md index e43156b..61e02a6 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -8,6 +8,14 @@ Valid params include `atol` and `rtol`, which can be used in combination, or alo is_correct = abs(res - ans) <= (atol + rtol*abs(ans)) ``` +Alternatively, `sig_figs` (or `significant_figures`) can be supplied to round both `res` and `ans` to N significant figures and require them to be equal: + +```python +is_correct = round_to_sig_figs(res, sig_figs) == round_to_sig_figs(ans, sig_figs) +``` + +`sig_figs` cannot be combined with `atol`/`rtol` — supplying both raises an exception. + ## Inputs ```json @@ -16,7 +24,8 @@ is_correct = abs(res - ans) <= (atol + rtol*abs(ans)) "answer": "", "params": { "atol": "", - "rtol": "" + "rtol": "", + "sig_figs": "" } } ``` @@ -29,6 +38,10 @@ Absolute tolerance parameter Relative tolerance parameter +### `sig_figs` + +Significant-figures parameter. Mutually exclusive with `atol`/`rtol`. + ## Outputs ```json { diff --git a/app/docs/user.md b/app/docs/user.md index 438113f..0e7b759 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -12,7 +12,7 @@ The left-hand side is the absolute difference between the student's response and ## Parameters -Both parameters default to `0` (exact match required) and can be used individually or together. +`atol` and `rtol` both default to `0` (exact match required) and can be used individually or together. `sig_figs` is an alternative comparison mode and cannot be used together with `atol`/`rtol`. ### `atol` — Absolute tolerance @@ -22,6 +22,10 @@ Specifies a fixed margin around the answer, regardless of its magnitude. Use thi Specifies an acceptable error as a fraction of the answer's magnitude. Use this when the answer is very large or very small and a percentage-based margin makes more sense than a fixed one. +### `sig_figs` — Significant figures + +Rounds both the response and the answer to the given number of significant figures and requires them to match. Use this when correctness is defined in terms of precision (e.g. "correct to 3 significant figures") rather than a fixed or proportional margin. Cannot be combined with `atol`/`rtol` — supplying both raises an error. + ## Examples ### Exact match (default) @@ -52,8 +56,18 @@ With answer `6.674e-11`, accepts any response within **1%** of the answer. Good Both tolerances contribute: with answer `9.81`, the allowed difference is `0.01 + 0.005 × 9.81 ≈ 0.059`. Useful when you want a minimum floor (`atol`) plus a proportional allowance (`rtol`). +### Significant figures + +```json +{ "sig_figs": 3 } +``` + +With answer `3.14159`, accepts any response that rounds to `3.14` (e.g. `3.136`–`3.144`). Unlike `atol`/`rtol`, this cannot be combined with tolerance params — `{ "sig_figs": 3, "atol": 0.01 }` will raise an error rather than be evaluated. + ## Notes **Note:** If the answer is not a number, all responses will generate an error. **Note:** If the response is not a number, a feedback message asking the student to submit a number will be returned. + +**Note:** `sig_figs` and `atol`/`rtol` are mutually exclusive; supplying both will generate an error. diff --git a/app/evaluation.py b/app/evaluation.py index 1119b6d..7572c28 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,5 +1,12 @@ from numpy import spacing + +def _round_to_sig_figs(value, sig_figs): + if value == 0: + return 0.0 + return float(f"{value:.{sig_figs}g}") + + def evaluation_function(response, answer, params) -> dict: """ Function used to grade a student response. @@ -21,14 +28,32 @@ def evaluation_function(response, answer, params) -> dict: to output the grading response. """ + sig_figs = params.get("significant_figures", params.get("sig_figs")) relative_tolerance = params.get("relative_tolerance", params.get("rtol", 0)) absolute_tolerance = params.get("absolute_tolerance", params.get("atol", 0)) + uses_tolerance = any( + key in params + for key in ("relative_tolerance", "rtol", "absolute_tolerance", "atol") + ) + + if sig_figs is not None and uses_tolerance: + raise Exception( + "significant_figures/sig_figs cannot be used together with " + "relative_tolerance/rtol or absolute_tolerance/atol." + ) + + if sig_figs is not None and (not isinstance(sig_figs, int) or sig_figs < 1): + raise Exception("significant_figures must be a positive integer.") + if not (isinstance(answer, int) or isinstance(answer, float)): raise Exception("Answer must be a number.") - allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) - allowed_diff += spacing(answer) + if sig_figs is None: + allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) + allowed_diff += spacing(answer) + else: + allowed_diff = None if not (isinstance(response, int) or isinstance(response, float)): return { @@ -38,11 +63,16 @@ def evaluation_function(response, answer, params) -> dict: "feedback": "Please enter a number.", } - real_diff = abs(response - answer) - allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) - allowed_diff += spacing(answer) - is_correct = bool(real_diff <= allowed_diff) + + if sig_figs is None: + is_correct = bool(real_diff <= allowed_diff) + else: + rounded_answer = _round_to_sig_figs(answer, sig_figs) + rounded_response = _round_to_sig_figs(response, sig_figs) + is_correct = bool( + abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) + ) return { "is_correct": is_correct, diff --git a/app/evaluation_test.py b/app/evaluation_test.py index fda062e..738926e 100644 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -274,5 +274,177 @@ def test_full_param_incorrect(self): self.assertEqual(response.get("is_correct"), False) + def test_sig_figs_correct(self): + body = { + "response": 3.14159, + "answer": 3.14, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_incorrect(self): + body = { + "response": 3.15, + "answer": 3.14159, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + + def test_sig_figs_negative_numbers(self): + body = { + "response": -3.14159, + "answer": -3.14, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_zero_answer(self): + body = { + "response": 0, + "answer": 0, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_invalid_value(self): + body = { + "response": 3.14, + "answer": 3.14159, + "params": { + "sig_figs": 0 + }, + } + + self.assertRaises( + Exception, + evaluation_function, + body["response"], + body["answer"], + body["params"], + ) + + def test_sig_figs_invalid_type(self): + body = { + "response": 3.14, + "answer": 3.14159, + "params": { + "sig_figs": 3.5 + }, + } + + self.assertRaises( + Exception, + evaluation_function, + body["response"], + body["answer"], + body["params"], + ) + + def test_sig_figs_and_tolerance_mutually_exclusive(self): + body = { + "response": 3.14, + "answer": 3.14159, + "params": { + "sig_figs": 3, + "atol": 0.1 + }, + } + + self.assertRaises( + Exception, + evaluation_function, + body["response"], + body["answer"], + body["params"], + ) + + def test_sig_figs_and_tolerance_mutually_exclusive_long_names(self): + body = { + "response": 3.14, + "answer": 3.14159, + "params": { + "significant_figures": 3, + "relative_tolerance": 0.1 + }, + } + + self.assertRaises( + Exception, + evaluation_function, + body["response"], + body["answer"], + body["params"], + ) + + def test_sig_figs_synonym_key(self): + body = { + "response": 3.14159, + "answer": 3.14, + "params": { + "significant_figures": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_non_numeric_response(self): + body = { + "response": "two", + "answer": 3.14, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + self.assertEqual("Please enter a number." in response.get("feedback"), True) + + def test_sig_figs_non_numeric_answer(self): + body = { + "response": 3.14, + "answer": "pi", + "params": { + "sig_figs": 3 + }, + } + + self.assertRaises( + Exception, + evaluation_function, + body["response"], + body["answer"], + body["params"], + ) + if __name__ == "__main__": unittest.main() diff --git a/readme.md b/readme.md index 7750fb5..edc8686 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # IsSimilar -This function checks whether a student's numeric response is within an acceptable tolerance of the correct answer, using absolute (`atol`) and relative (`rtol`) tolerance parameters. The comparison follows the formula: `|response - answer| ≤ atol + rtol × |answer|`. By default both tolerances are 0, requiring an exact match (within floating-point precision). +This function checks whether a student's numeric response matches the correct answer, using either absolute (`atol`) and relative (`rtol`) tolerance parameters, or a `sig_figs` significant-figures check. The tolerance comparison follows the formula: `|response - answer| ≤ atol + rtol × |answer|`. By default both tolerances are 0, requiring an exact match (within floating-point precision). Alternatively, `sig_figs` rounds both the response and answer to N significant figures and requires them to match; it cannot be combined with `atol`/`rtol`. For more information, look at the docs in `app/docs/`. From 4730319c5309db4ed3d7c5393bb2b4c079db5a3c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 14:08:43 +0100 Subject: [PATCH 2/5] Refactored evaluation logic to modularize tolerance and significant-figures handling --- app/evaluation.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index 7572c28..d21bfe9 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -7,6 +7,16 @@ def _round_to_sig_figs(value, sig_figs): return float(f"{value:.{sig_figs}g}") +def _sig_figs_match(response, answer, sig_figs): + rounded_answer = _round_to_sig_figs(answer, sig_figs) + rounded_response = _round_to_sig_figs(response, sig_figs) + return abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) + + +def _tolerance_allowed_diff(answer, relative_tolerance, absolute_tolerance): + return absolute_tolerance + relative_tolerance * abs(answer) + spacing(answer) + + def evaluation_function(response, answer, params) -> dict: """ Function used to grade a student response. @@ -49,11 +59,9 @@ def evaluation_function(response, answer, params) -> dict: if not (isinstance(answer, int) or isinstance(answer, float)): raise Exception("Answer must be a number.") - if sig_figs is None: - allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) - allowed_diff += spacing(answer) - else: - allowed_diff = None + allowed_diff = None if sig_figs is not None else _tolerance_allowed_diff( + answer, relative_tolerance, absolute_tolerance + ) if not (isinstance(response, int) or isinstance(response, float)): return { @@ -64,18 +72,13 @@ def evaluation_function(response, answer, params) -> dict: } real_diff = abs(response - answer) - - if sig_figs is None: - is_correct = bool(real_diff <= allowed_diff) + if sig_figs is not None: + is_correct = _sig_figs_match(response, answer, sig_figs) else: - rounded_answer = _round_to_sig_figs(answer, sig_figs) - rounded_response = _round_to_sig_figs(response, sig_figs) - is_correct = bool( - abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) - ) + is_correct = real_diff <= allowed_diff return { - "is_correct": is_correct, + "is_correct": bool(is_correct), "real_diff": real_diff, "allowed_diff": allowed_diff, "feedback": "", From 1d61b3194c4172efbd9e3f727f403f34467118c0 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 14:11:34 +0100 Subject: [PATCH 3/5] Refactored evaluation logic to separate significant-figures and tolerance handling into dedicated helper functions --- app/evaluation.py | 65 +++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index d21bfe9..94e714e 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -7,14 +7,46 @@ def _round_to_sig_figs(value, sig_figs): return float(f"{value:.{sig_figs}g}") -def _sig_figs_match(response, answer, sig_figs): +def _evaluate_sig_figs(response, answer, sig_figs): + if not (isinstance(response, int) or isinstance(response, float)): + return { + "is_correct": False, + "real_diff": None, + "allowed_diff": None, + "feedback": "Please enter a number.", + } + rounded_answer = _round_to_sig_figs(answer, sig_figs) rounded_response = _round_to_sig_figs(response, sig_figs) - return abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) + is_correct = abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) + + return { + "is_correct": bool(is_correct), + "real_diff": abs(response - answer), + "allowed_diff": None, + "feedback": "", + } -def _tolerance_allowed_diff(answer, relative_tolerance, absolute_tolerance): - return absolute_tolerance + relative_tolerance * abs(answer) + spacing(answer) +def _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance): + allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) + spacing(answer) + + if not (isinstance(response, int) or isinstance(response, float)): + return { + "is_correct": False, + "real_diff": None, + "allowed_diff": allowed_diff, + "feedback": "Please enter a number.", + } + + real_diff = abs(response - answer) + + return { + "is_correct": bool(real_diff <= allowed_diff), + "real_diff": real_diff, + "allowed_diff": allowed_diff, + "feedback": "", + } def evaluation_function(response, answer, params) -> dict: @@ -59,27 +91,6 @@ def evaluation_function(response, answer, params) -> dict: if not (isinstance(answer, int) or isinstance(answer, float)): raise Exception("Answer must be a number.") - allowed_diff = None if sig_figs is not None else _tolerance_allowed_diff( - answer, relative_tolerance, absolute_tolerance - ) - - if not (isinstance(response, int) or isinstance(response, float)): - return { - "is_correct": False, - "real_diff": None, - "allowed_diff": allowed_diff, - "feedback": "Please enter a number.", - } - - real_diff = abs(response - answer) if sig_figs is not None: - is_correct = _sig_figs_match(response, answer, sig_figs) - else: - is_correct = real_diff <= allowed_diff - - return { - "is_correct": bool(is_correct), - "real_diff": real_diff, - "allowed_diff": allowed_diff, - "feedback": "", - } + return _evaluate_sig_figs(response, answer, sig_figs) + return _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance) From 90e68a49596a5254f06a71cc11c2dc082be8acae Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 14:14:01 +0100 Subject: [PATCH 4/5] Refactored evaluation logic to centralize result construction and number validation --- app/evaluation.py | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/app/evaluation.py b/app/evaluation.py index 94e714e..04a894c 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -1,52 +1,45 @@ from numpy import spacing +def _is_number(value): + return isinstance(value, int) or isinstance(value, float) + + def _round_to_sig_figs(value, sig_figs): if value == 0: return 0.0 return float(f"{value:.{sig_figs}g}") +def _result(is_correct, real_diff, allowed_diff, feedback=""): + return { + "is_correct": bool(is_correct), + "real_diff": real_diff, + "allowed_diff": allowed_diff, + "feedback": feedback, + } + + def _evaluate_sig_figs(response, answer, sig_figs): - if not (isinstance(response, int) or isinstance(response, float)): - return { - "is_correct": False, - "real_diff": None, - "allowed_diff": None, - "feedback": "Please enter a number.", - } + if not _is_number(response): + return _result(False, None, None, "Please enter a number.") rounded_answer = _round_to_sig_figs(answer, sig_figs) rounded_response = _round_to_sig_figs(response, sig_figs) is_correct = abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) - return { - "is_correct": bool(is_correct), - "real_diff": abs(response - answer), - "allowed_diff": None, - "feedback": "", - } + return _result(is_correct, abs(response - answer), None) def _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance): allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) + spacing(answer) - if not (isinstance(response, int) or isinstance(response, float)): - return { - "is_correct": False, - "real_diff": None, - "allowed_diff": allowed_diff, - "feedback": "Please enter a number.", - } + if not _is_number(response): + return _result(False, None, allowed_diff, "Please enter a number.") real_diff = abs(response - answer) - return { - "is_correct": bool(real_diff <= allowed_diff), - "real_diff": real_diff, - "allowed_diff": allowed_diff, - "feedback": "", - } + return _result(real_diff <= allowed_diff, real_diff, allowed_diff) def evaluation_function(response, answer, params) -> dict: @@ -88,7 +81,7 @@ def evaluation_function(response, answer, params) -> dict: if sig_figs is not None and (not isinstance(sig_figs, int) or sig_figs < 1): raise Exception("significant_figures must be a positive integer.") - if not (isinstance(answer, int) or isinstance(answer, float)): + if not _is_number(answer): raise Exception("Answer must be a number.") if sig_figs is not None: From a53ddc0870c95f65d82e4dbbfbd59768b85dba58 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 21 Jul 2026 09:12:13 +0100 Subject: [PATCH 5/5] Enhanced `sig_figs` to validate written precision, support trailing zeros, and enforce string-based responses for accurate significant-figure handling. Updated docs, tests, and logic accordingly. --- app/docs/dev.md | 11 ++- app/docs/user.md | 14 +++- app/evaluation.py | 51 ++++++++++++- app/evaluation_test.py | 162 +++++++++++++++++++++++++++++++++++++++-- readme.md | 2 +- 5 files changed, 222 insertions(+), 18 deletions(-) diff --git a/app/docs/dev.md b/app/docs/dev.md index 61e02a6..44f215a 100644 --- a/app/docs/dev.md +++ b/app/docs/dev.md @@ -8,19 +8,24 @@ Valid params include `atol` and `rtol`, which can be used in combination, or alo is_correct = abs(res - ans) <= (atol + rtol*abs(ans)) ``` -Alternatively, `sig_figs` (or `significant_figures`) can be supplied to round both `res` and `ans` to N significant figures and require them to be equal: +Alternatively, `sig_figs` (or `significant_figures`) can be supplied to require both a numeric match and a precision match to N significant figures: ```python -is_correct = round_to_sig_figs(res, sig_figs) == round_to_sig_figs(ans, sig_figs) +is_correct = ( + round_to_sig_figs(res, sig_figs) == round_to_sig_figs(ans, sig_figs) + and count_sig_figs(res) == sig_figs +) ``` `sig_figs` cannot be combined with `atol`/`rtol` — supplying both raises an exception. +In `sig_figs` mode, `response` must be a **string** (e.g. `"92.00"`), not a pre-parsed `int`/`float` — significant-figure counting depends on the exact digits written (trailing zeros, decimal point placement), which a parsed number can't preserve (`92.0 == 92.00 == 92`). `_split_numeric_string` in `evaluation.py` validates and decomposes the string (sign / integer part / fractional part / exponent) using `str.isdigit()` on each part; if `response` isn't a string, or isn't a valid numeral, the sig-figs check returns the standard "Please enter a number." result rather than raising. `_count_sig_figs` then applies the standard significant-figures counting rules (non-zero digits always count; zeros between digits count; leading zeros don't; trailing zeros only count if a decimal point was written) to the parsed parts. + ## Inputs ```json { - "response": "", + "response": "", "answer": "", "params": { "atol": "", diff --git a/app/docs/user.md b/app/docs/user.md index 0e7b759..0c31029 100644 --- a/app/docs/user.md +++ b/app/docs/user.md @@ -24,7 +24,17 @@ Specifies an acceptable error as a fraction of the answer's magnitude. Use this ### `sig_figs` — Significant figures -Rounds both the response and the answer to the given number of significant figures and requires them to match. Use this when correctness is defined in terms of precision (e.g. "correct to 3 significant figures") rather than a fixed or proportional margin. Cannot be combined with `atol`/`rtol` — supplying both raises an error. +Checks two things: that the response's numeric value rounds to the same value as the answer at the given number of significant figures, and that the response was itself *written* with exactly that many significant figures. Use this when correctness is defined in terms of precision (e.g. "correct to 3 significant figures") rather than a fixed or proportional margin. Cannot be combined with `atol`/`rtol` — supplying both raises an error. + +In `sig_figs` mode, **`response` must be supplied as a string** (e.g. `"92.00"`, not `92.0`), so that trailing zeros and decimal points are preserved exactly as written — a parsed number can't distinguish `92` from `92.00`. If `response` isn't a string, or isn't a valid number, the student is asked to enter a number, the same as any other non-numeric response. + +Significant figures are counted as follows: +- All non-zero digits are significant. +- Zeros between non-zero digits are significant (e.g. `2051` has 4). +- Leading zeros are not significant (e.g. `0.0032` has 2). +- Trailing zeros after a decimal point are significant (e.g. `92.00` has 4). +- Trailing zeros in a whole number are only significant if a decimal point is shown (e.g. `540` has 2, but `540.` has 3). +- In scientific notation, only the digits before the exponent count (e.g. `5.02e4` has 3). ## Examples @@ -62,7 +72,7 @@ Both tolerances contribute: with answer `9.81`, the allowed difference is `0.01 { "sig_figs": 3 } ``` -With answer `3.14159`, accepts any response that rounds to `3.14` (e.g. `3.136`–`3.144`). Unlike `atol`/`rtol`, this cannot be combined with tolerance params — `{ "sig_figs": 3, "atol": 0.01 }` will raise an error rather than be evaluated. +With answer `3.14159`, the response `"3.14"` is accepted (correct value, 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. Unlike `atol`/`rtol`, this cannot be combined with tolerance params — `{ "sig_figs": 3, "atol": 0.01 }` will raise an error rather than be evaluated. ## Notes diff --git a/app/evaluation.py b/app/evaluation.py index 04a894c..a83fa16 100644 --- a/app/evaluation.py +++ b/app/evaluation.py @@ -11,6 +11,40 @@ def _round_to_sig_figs(value, sig_figs): return float(f"{value:.{sig_figs}g}") +def _split_numeric_string(value): + 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 _result(is_correct, real_diff, allowed_diff, feedback=""): return { "is_correct": bool(is_correct), @@ -21,14 +55,23 @@ def _result(is_correct, real_diff, allowed_diff, feedback=""): def _evaluate_sig_figs(response, answer, sig_figs): - if not _is_number(response): + parts = _split_numeric_string(response) + if parts is None: return _result(False, None, None, "Please enter a number.") + response_value = float(response) rounded_answer = _round_to_sig_figs(answer, sig_figs) - rounded_response = _round_to_sig_figs(response, sig_figs) - is_correct = abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) + rounded_response = _round_to_sig_figs(response_value, sig_figs) + numeric_correct = abs(rounded_response - rounded_answer) <= spacing(abs(rounded_answer)) + + precision_correct = response_value == 0 or _count_sig_figs(*parts) == sig_figs + is_correct = numeric_correct and precision_correct + + feedback = "" + if numeric_correct and not precision_correct: + feedback = f"Please give your answer to {sig_figs} significant figures." - return _result(is_correct, abs(response - answer), None) + return _result(is_correct, abs(response_value - answer), None, feedback) def _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance): diff --git a/app/evaluation_test.py b/app/evaluation_test.py index 738926e..b69dfd6 100644 --- a/app/evaluation_test.py +++ b/app/evaluation_test.py @@ -276,8 +276,8 @@ def test_full_param_incorrect(self): def test_sig_figs_correct(self): body = { - "response": 3.14159, - "answer": 3.14, + "response": "3.14", + "answer": 3.14159, "params": { "sig_figs": 3 }, @@ -290,7 +290,7 @@ def test_sig_figs_correct(self): def test_sig_figs_incorrect(self): body = { - "response": 3.15, + "response": "3.15", "answer": 3.14159, "params": { "sig_figs": 3 @@ -302,10 +302,42 @@ def test_sig_figs_incorrect(self): self.assertEqual(response.get("is_correct"), False) + def test_sig_figs_too_many_digits(self): + body = { + "response": "3.14159", + "answer": 3.14159, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + self.assertEqual( + "significant figures" in response.get("feedback"), True) + + def test_sig_figs_too_few_digits(self): + body = { + "response": "3.1", + "answer": 3.10, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + self.assertEqual( + "significant figures" in response.get("feedback"), True) + def test_sig_figs_negative_numbers(self): body = { - "response": -3.14159, - "answer": -3.14, + "response": "-3.14", + "answer": -3.14159, "params": { "sig_figs": 3 }, @@ -318,7 +350,7 @@ def test_sig_figs_negative_numbers(self): def test_sig_figs_zero_answer(self): body = { - "response": 0, + "response": "0", "answer": 0, "params": { "sig_figs": 3 @@ -330,6 +362,120 @@ def test_sig_figs_zero_answer(self): self.assertEqual(response.get("is_correct"), True) + def test_sig_figs_trailing_decimal_zeros_significant(self): + body = { + "response": "92.00", + "answer": 92, + "params": { + "sig_figs": 4 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_leading_zeros_not_significant(self): + body = { + "response": "0.0032", + "answer": 0.0032, + "params": { + "sig_figs": 2 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_whole_number_trailing_zeros_not_significant(self): + body = { + "response": "540", + "answer": 540, + "params": { + "sig_figs": 2 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_whole_number_trailing_zeros_not_significant_wrong_count(self): + body = { + "response": "540", + "answer": 540, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + + def test_sig_figs_explicit_decimal_point_trailing_zeros_significant(self): + body = { + "response": "540.", + "answer": 540, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_scientific_notation(self): + body = { + "response": "5.02e4", + "answer": 50200, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), True) + + def test_sig_figs_response_not_string(self): + body = { + "response": 3.14, + "answer": 3.14159, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + self.assertEqual("Please enter a number." in response.get("feedback"), True) + + def test_sig_figs_dangling_exponent_marker(self): + body = { + "response": "3.14e", + "answer": 3.14159, + "params": { + "sig_figs": 3 + }, + } + + response = evaluation_function(body['response'], body['answer'], + body.get('params', {})) + + self.assertEqual(response.get("is_correct"), False) + self.assertEqual("Please enter a number." in response.get("feedback"), True) + def test_sig_figs_invalid_value(self): body = { "response": 3.14, @@ -402,8 +548,8 @@ def test_sig_figs_and_tolerance_mutually_exclusive_long_names(self): def test_sig_figs_synonym_key(self): body = { - "response": 3.14159, - "answer": 3.14, + "response": "3.14", + "answer": 3.14159, "params": { "significant_figures": 3 }, diff --git a/readme.md b/readme.md index edc8686..a515c67 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # IsSimilar -This function checks whether a student's numeric response matches the correct answer, using either absolute (`atol`) and relative (`rtol`) tolerance parameters, or a `sig_figs` significant-figures check. The tolerance comparison follows the formula: `|response - answer| ≤ atol + rtol × |answer|`. By default both tolerances are 0, requiring an exact match (within floating-point precision). Alternatively, `sig_figs` rounds both the response and answer to N significant figures and requires them to match; it cannot be combined with `atol`/`rtol`. +This function checks whether a student's numeric response matches the correct answer, using either absolute (`atol`) and relative (`rtol`) tolerance parameters, or a `sig_figs` significant-figures check. The tolerance comparison follows the formula: `|response - answer| ≤ atol + rtol × |answer|`. By default both tolerances are 0, requiring an exact match (within floating-point precision). Alternatively, `sig_figs` requires both the response's value and its written precision to match the answer to N significant figures (the response must be given as a string, e.g. `"92.00"`, so trailing zeros and decimal points are preserved); it cannot be combined with `atol`/`rtol`. For more information, look at the docs in `app/docs/`.