File size: 6,807 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """Error-classification validation harness (Wave 1, Item 10).
Deterministic rules only β no LLM, no model calls, no network.
Tests 6 canonical error-classification cases and verifies the v1.1 graceful-
degradation rule: where deterministic rules cannot reliably separate
``unit_error`` from ``calculation_error``, the distinction merges into the
broader ``numerical_error`` bucket (Product Strategy Β§15.3).
"""
from __future__ import annotations
from app.services.mastery_engine import classify_error
# ---------------------------------------------------------------------------
# 1. Blank / whitespace β not_attempted
# ---------------------------------------------------------------------------
def test_blank_answer_is_not_attempted() -> None:
assert (
classify_error(
question_type="numerical",
student_answer="",
correct_answer="340 m/s",
)
== "not_attempted"
)
def test_whitespace_only_answer_is_not_attempted() -> None:
assert (
classify_error(
question_type="numerical",
student_answer=" \t \n ",
correct_answer="340 m/s",
)
== "not_attempted"
)
# ---------------------------------------------------------------------------
# 2. Numerical β wrong number, no reliable unit signal β numerical_error
# (degraded; was calculation_error in the old code)
# ---------------------------------------------------------------------------
def test_wrong_number_degrades_to_numerical_error() -> None:
assert (
classify_error(
question_type="numerical",
student_answer="The answer is 350 m/s",
correct_answer="340 m/s",
)
== "numerical_error"
)
def test_wrong_number_with_units_degrades_to_numerical_error() -> None:
# Both numbers and units are present but don't match. Old code would
# try to branch on calculation_error vs unit_error using fragile substring
# checks. The merged bucket is safer.
assert (
classify_error(
question_type="numerical",
student_answer="350 m/s",
correct_answer="340 m/s",
)
== "numerical_error"
)
# ---------------------------------------------------------------------------
# 3. Short text / board-style β incomplete_explanation
# ---------------------------------------------------------------------------
def test_short_text_with_no_keywords_is_incomplete() -> None:
assert (
classify_error(
question_type="short",
student_answer="sound is fast",
correct_answer="Equal natural frequencies cause vibration with maximum amplitude",
expected_keywords=["natural frequency", "maximum amplitude"],
)
== "concept_misunderstanding"
)
def test_very_short_answer_is_incomplete_explanation() -> None:
assert (
classify_error(
question_type="short",
student_answer="yes",
correct_answer="A detailed explanation of resonance",
expected_keywords=[],
)
== "incomplete_explanation"
)
# ---------------------------------------------------------------------------
# 4. Correct answer β classify_error is only called on WRONG answers, so
# there is no "correct" category. But verify a near-correct answer that
# would be graded wrong by a rubric still classifies sensibly.
# ---------------------------------------------------------------------------
def test_near_correct_short_answer_is_missing_keyword() -> None:
assert (
classify_error(
question_type="short",
student_answer="It vibrates with maximum amplitude",
correct_answer="Equal natural frequencies cause vibration with maximum amplitude",
expected_keywords=["natural frequency", "maximum amplitude"],
)
== "missing_exam_keyword"
)
# ---------------------------------------------------------------------------
# 5. Formula selection β no numbers in a numerical answer
# ---------------------------------------------------------------------------
def test_no_numbers_in_numerical_is_formula_selection() -> None:
assert (
classify_error(
question_type="numerical",
student_answer="I am not sure how to start",
correct_answer="340 m/s",
)
== "formula_selection"
)
def test_no_numbers_short_text_in_numerical_is_formula_selection() -> None:
assert (
classify_error(
question_type="numerical",
student_answer="I don't know the formula",
correct_answer="340 m/s",
)
== "formula_selection"
)
# ---------------------------------------------------------------------------
# 6. Unit error β high-confidence deterministic cases only
# ---------------------------------------------------------------------------
def test_exact_magnitude_missing_unit_is_unit_error() -> None:
# Student wrote the right number but no unit β reliably a unit error.
assert (
classify_error(
question_type="numerical",
student_answer="340",
correct_answer="340 m/s",
)
== "unit_error"
)
def test_power_of_ten_ratio_is_unit_error() -> None:
# 34000 vs 340 β 100x β unit conversion slip. Deterministic.
assert (
classify_error(
question_type="numerical",
student_answer="The speed is 34000",
correct_answer="340 m/s",
)
== "unit_error"
)
# ---------------------------------------------------------------------------
# Graceful degradation: verify the old fragile cases now merge
# ---------------------------------------------------------------------------
def test_old_substring_unit_false_positive_merges_to_numerical() -> None:
# "my answer is 5" contains "m" (metres) under the old substring check.
# The new word-boundary detector must NOT fire here.
assert (
classify_error(
question_type="numerical",
student_answer="my answer is 5",
correct_answer="340 m/s",
)
== "numerical_error"
)
def test_mcq_blank_is_not_attempted() -> None:
# Even MCQ blanks must be not_attempted, not concept_misunderstanding.
assert (
classify_error(
question_type="mcq",
student_answer="",
correct_answer="B",
)
== "not_attempted"
)
def test_whitespace_mcq_is_not_attempted() -> None:
assert (
classify_error(
question_type="mcq",
student_answer=" ",
correct_answer="B",
)
== "not_attempted"
)
|