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
24 changes: 21 additions & 3 deletions app/docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,29 @@ Valid params include `absolute_tolerance` and `relative_tolerance`, which can be
is_correct = abs(res - ans) <= (absolute_tolerance + relative_tolerance*abs(ans))
```

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)
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": "<number>",
"response": "<number | string, use string for sig_figs mode>",
"answer": "<number>",
"params": {
"absolute_tolerance": "<number>",
"relative_tolerance": "<number>"
"relative_tolerance": "<number>",
"sig_figs": "<int>"
}
}
```
Expand All @@ -29,12 +43,16 @@ Absolute tolerance parameter

Relative tolerance parameter

### `sig_figs`

Significant-figures parameter. Mutually exclusive with `atol`/`rtol`.

## Outputs
```json
{
"is_correct": "<bool>",
"real_diff": "<number>",
"allowed_diff": "<number>",
"allowed_diff": "<number>"
}
```

Expand Down
26 changes: 25 additions & 1 deletion app/docs/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

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.

Should this use the long form, absolute_tolerance?


### `absolute_tolerance` — Absolute tolerance

Expand All @@ -22,6 +22,20 @@ 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

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

### Exact match (default)
Expand Down Expand Up @@ -52,8 +66,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 (`absolute_tolerance`) plus a proportional allowance (`relative_tolerance`).

### Significant figures

```json
{ "sig_figs": 3 }
```

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

**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.
122 changes: 102 additions & 20 deletions app/evaluation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,90 @@
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 _split_numeric_string(value):
if not isinstance(value, str):
return None
stripped = value.strip()
body = stripped[1:] if stripped[:1] in ("+", "-") else stripped

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.

Will we also accept plus/minus (pm)? We have some accommodation for that concept. But perhaps it's at the preview stage which splits it into a list of multiple entries?


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

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

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),
"real_diff": real_diff,
"allowed_diff": allowed_diff,
"feedback": feedback,
}


def _evaluate_sig_figs(response, answer, sig_figs):
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_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."

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.

I think Karl started collecting all feedback strings in one place, so that they can be easily maintained. Could you find that and put this string there and call it?


return _result(is_correct, abs(response_value - answer), None, feedback)


def _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance):
allowed_diff = absolute_tolerance + relative_tolerance * abs(answer) + spacing(answer)

if not _is_number(response):
return _result(False, None, allowed_diff, "Please enter a number.")

real_diff = abs(response - answer)

return _result(real_diff <= allowed_diff, real_diff, allowed_diff)


def evaluation_function(response, answer, params) -> dict:
"""
Function used to grade a student response.
Expand All @@ -21,30 +106,27 @@ 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))

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)
uses_tolerance = any(
key in params
for key in ("relative_tolerance", "rtol", "absolute_tolerance", "atol")
)

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 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.")

real_diff = abs(response - answer)
is_correct = bool(real_diff <= allowed_diff)
if not _is_number(answer):
raise Exception("Answer must be a number.")

return {
"is_correct": is_correct,
"real_diff": real_diff,
"allowed_diff": allowed_diff,
"feedback": "",
}
if sig_figs is not None:
return _evaluate_sig_figs(response, answer, sig_figs)
return _evaluate_tolerance(response, answer, relative_tolerance, absolute_tolerance)
Loading
Loading