"""
Per-question-type grading helpers for :mod:`~nbgrader_jupyterquiz.grader.autograde`.
Each ``grade_*`` function takes a parsed question dict plus the
student's recorded response payload and returns ``True`` iff the
response satisfies that question's correctness criteria. Grading is
all-or-nothing per question — partial credit at the quiz level falls
out of summing per-question outcomes in
:class:`~nbgrader_jupyterquiz.grader.autograde.QuizResult`.
"""
from typing import Any
[docs]
def grade_multiple_choice(question: dict[str, Any], recorded: dict[str, Any]) -> bool:
"""
Grade a single-choice question.
Parameters
----------
question : dict
Question dict with its answer list.
recorded : dict
Student's recorded response with a ``selected`` string.
Returns
-------
bool
True iff ``selected`` matches a ``correct: true`` answer.
"""
if recorded.get("type") != "multiple_choice":
return False
selected = recorded.get("selected")
if not isinstance(selected, str):
return False
for a in question.get("answers", []):
if a.get("correct") and a.get("answer") == selected:
return True
return False
[docs]
def grade_many_choice(question: dict[str, Any], recorded: dict[str, Any]) -> bool:
"""
Grade a many-choice question (set equality).
Parameters
----------
question : dict
Question dict with its answer list.
recorded : dict
Student's recorded response with a ``selected`` list.
Returns
-------
bool
True iff the set of selected answers equals the set of correct
answers.
"""
if recorded.get("type") != "many_choice":
return False
selected = recorded.get("selected")
if not isinstance(selected, list):
return False
correct_set = {a.get("answer") for a in question.get("answers", []) if a.get("correct")}
return set(selected) == correct_set
[docs]
def grade_numeric(question: dict[str, Any], recorded: dict[str, Any]) -> bool:
"""
Grade a numeric question (value or range, optional precision).
Parameters
----------
question : dict
Question dict with its answer list (values and/or ranges).
recorded : dict
Student's recorded response with a ``parsed`` numeric field.
Returns
-------
bool
True iff ``parsed`` matches a ``correct`` value (at the
configured precision) or falls inside a ``correct`` range.
"""
if recorded.get("type") != "numeric":
return False
parsed = recorded.get("parsed")
if not isinstance(parsed, (int, float)):
return False
precision = question.get("precision")
for a in question.get("answers", []):
if not a.get("correct"):
continue
atype = a.get("type")
if atype == "value":
expected = float(a.get("value"))
if precision and precision > 0:
if _round_to_precision(parsed, precision) == _round_to_precision(expected, precision):
return True
elif parsed == expected:
return True
elif atype == "range":
lo, hi = a.get("range", [None, None])
if lo is not None and hi is not None and lo <= parsed <= hi:
return True
return False
[docs]
def grade_string(question: dict[str, Any], recorded: dict[str, Any]) -> bool:
"""
Grade a string question (exact or fuzzy match).
Parameters
----------
question : dict
Question dict with its answer list; each answer may set
``match_case`` and ``fuzzy_threshold``.
recorded : dict
Student's recorded response with a ``value`` string.
Returns
-------
bool
True iff ``value`` matches any correct answer (honouring
``match_case`` and ``fuzzy_threshold`` using Levenshtein
similarity).
"""
if recorded.get("type") != "string":
return False
value = recorded.get("value")
if not isinstance(value, str):
return False
for a in question.get("answers", []):
if not a.get("correct"):
continue
expected = a.get("answer", "")
if a.get("match_case"):
match = value == expected
else:
match = value.lower() == expected.lower()
if match:
return True
threshold = a.get("fuzzy_threshold")
if threshold:
distance = _levenshtein(
value if a.get("match_case") else value.lower(),
expected if a.get("match_case") else expected.lower(),
)
max_len = max(len(value), len(expected), 1)
if 1 - (distance / max_len) >= threshold:
return True
return False
[docs]
def expected_answer(question: dict[str, Any]) -> Any:
"""
Derive a human-readable representation of a question's expected answer.
Parameters
----------
question : dict
Question dict as produced by :mod:`~nbgrader_jupyterquiz.grader.parse`.
Returns
-------
list or None
A list of expected answer texts (choice/string) or values/ranges
(numeric). ``None`` for unsupported question types.
"""
qtype = question.get("type")
answers = question.get("answers", [])
correct_answers = [a for a in answers if a.get("correct")]
if qtype in ("multiple_choice", "many_choice", "string"):
return [a.get("answer") for a in correct_answers]
if qtype == "numeric":
expected: list[Any] = []
for a in correct_answers:
if a.get("type") == "value":
expected.append(a.get("value"))
elif a.get("type") == "range":
expected.append(tuple(a.get("range", [])))
return expected
return None
def _round_to_precision(x: float, precision: int) -> float:
"""
Round a float to a given number of significant digits.
Mirrors JavaScript's ``Number.prototype.toPrecision`` so numeric
questions with a ``[N]`` precision marker score the same way the
display JS evaluates them in the browser.
Parameters
----------
x : float
Value to round.
precision : int
Number of significant digits.
Returns
-------
float
Rounded value.
"""
if x == 0:
return 0.0
return float(f"{x:.{precision}g}")
def _levenshtein(a: str, b: str) -> int:
"""
Compute the Levenshtein (edit) distance between two strings.
Parameters
----------
a : str
First string.
b : str
Second string.
Returns
-------
int
Minimum number of single-character insertions, deletions, or
substitutions required to transform ``a`` into ``b``.
"""
if not a:
return len(b)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
curr = [i] + [0] * len(b)
for j, cb in enumerate(b, 1):
cost = 0 if ca == cb else 1
curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
prev = curr
return prev[-1]