DocDoeAI / tests /test_mastery_engine.py
asnannp's picture
Deploy backend cd4237ff: support routes + rate limit + exam_date nullable + upload 413 fix
7c6ffa6
Raw
History Blame Contribute Delete
7.26 kB
"""Unit tests for the deterministic mastery engine (no DB, no API)."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from app.services.mastery_engine import (
EvidenceEvent,
MasterySnapshot,
apply_evidence,
classify_error,
compute_state,
effective_strength,
review_interval_days,
)
NOW = datetime(2026, 7, 16, 10, 0, tzinfo=timezone.utc)
EXAM = date(2026, 9, 30)
def _event(**overrides) -> EvidenceEvent:
base = dict(kind="checkpoint_mcq", correct=True, at=NOW, question_id="q1")
base.update(overrides)
return EvidenceEvent(**base)
def test_stronger_evidence_moves_score_more() -> None:
weak = MasterySnapshot()
strong = MasterySnapshot()
apply_evidence(weak, _event(kind="lesson_completed"), exam_date=EXAM, has_open_repair=False)
apply_evidence(strong, _event(kind="checkpoint_numerical"), exam_date=EXAM, has_open_repair=False)
assert strong.score > weak.score
def test_hint_halves_the_strength_of_a_correct_answer() -> None:
evidence: dict = {}
without_hint = effective_strength(_event(hint_used=False), evidence)
with_hint = effective_strength(_event(hint_used=True), evidence)
assert with_hint == without_hint * 0.5
def test_repeating_a_memorized_question_earns_almost_nothing() -> None:
snapshot = MasterySnapshot()
apply_evidence(snapshot, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False)
first_score = snapshot.score
update = apply_evidence(snapshot, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False)
# Second correct on the SAME question applies dampened strength.
assert update.applied_strength < 0.2
fresh = MasterySnapshot()
apply_evidence(fresh, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False)
second_unique = apply_evidence(fresh, _event(question_id="q2"), exam_date=EXAM, has_open_repair=False)
assert second_unique.applied_strength > update.applied_strength
assert snapshot.score >= first_score # never punished for correct
def test_wrong_answer_reduces_score_confidence_and_streak() -> None:
snapshot = MasterySnapshot()
apply_evidence(snapshot, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False)
apply_evidence(snapshot, _event(question_id="q2"), exam_date=EXAM, has_open_repair=False)
score_before = snapshot.score
confidence_before = snapshot.confidence
update = apply_evidence(
snapshot,
_event(question_id="q3", correct=False, error_category="unit_error"),
exam_date=EXAM,
has_open_repair=True,
)
assert snapshot.score < score_before
assert snapshot.confidence < confidence_before
assert update.consecutive_success == 0
assert update.after_state == "needs_repair"
assert snapshot.evidence["error_categories"]["unit_error"] == 1
def test_secure_requires_score_confidence_and_consecutive_recalls() -> None:
snapshot = MasterySnapshot()
for question in ("q1", "q2", "q3", "q4", "q5"):
update = apply_evidence(
snapshot,
_event(kind="checkpoint_numerical", question_id=question),
exam_date=EXAM,
has_open_repair=False,
)
assert update.after_state == "secure"
assert snapshot.score >= 75.0
assert update.consecutive_success >= 2
def test_state_priority_repair_beats_secure_numbers() -> None:
state = compute_state(
score=95.0,
confidence=0.9,
consecutive_success=5,
attempts_count=8,
has_check_evidence=True,
has_open_repair=True,
next_review_at=None,
now=NOW,
exam_date=EXAM,
)
assert state == "needs_repair"
def test_revision_due_and_at_risk_states() -> None:
due = compute_state(
score=80.0,
confidence=0.7,
consecutive_success=3,
attempts_count=5,
has_check_evidence=True,
has_open_repair=False,
next_review_at=NOW - timedelta(days=1),
now=NOW,
exam_date=EXAM,
)
assert due == "revision_due"
at_risk = compute_state(
score=80.0,
confidence=0.7,
consecutive_success=3,
attempts_count=5,
has_check_evidence=True,
has_open_repair=False,
next_review_at=NOW - timedelta(days=6),
now=NOW,
exam_date=EXAM,
)
assert at_risk == "at_risk"
weak_near_exam = compute_state(
score=40.0,
confidence=0.4,
consecutive_success=0,
attempts_count=3,
has_check_evidence=True,
has_open_repair=False,
next_review_at=NOW - timedelta(days=1),
now=NOW,
exam_date=NOW.date() + timedelta(days=7),
)
assert weak_near_exam == "at_risk"
def test_review_intervals_follow_documented_policy() -> None:
assert review_interval_days("needs_repair", 0.9, 5) == 1
assert review_interval_days("developing", 0.5, 1) == 2
assert review_interval_days("secure", 0.5, 2) == 4
assert review_interval_days("secure", 0.8, 2) == 7
assert review_interval_days("secure", 0.8, 4) == 16 # 7 * 1.5^2, rounded
assert review_interval_days("secure", 0.9, 10) == 21 # capped
def test_review_never_scheduled_after_exam() -> None:
snapshot = MasterySnapshot()
close_exam = NOW.date() + timedelta(days=3)
apply_evidence(
snapshot,
_event(kind="checkpoint_numerical"),
exam_date=close_exam,
has_open_repair=False,
)
assert snapshot.next_review_at is not None
assert snapshot.next_review_at.date() < close_exam
def test_classify_error_categories() -> None:
assert (
classify_error(question_type="mcq", student_answer="metre", correct_answer="hertz")
== "concept_misunderstanding"
)
assert (
classify_error(question_type="diagram", student_answer="x", correct_answer="y")
== "diagram_interpretation"
)
# Power-of-ten slip => unit conversion error
assert (
classify_error(
question_type="numerical",
student_answer="The speed is 34000",
correct_answer="340 m/s",
)
== "unit_error"
)
# No numbers at all => didn't reach a formula
assert (
classify_error(
question_type="numerical",
student_answer="I am not sure how to start",
correct_answer="340 m/s",
)
== "formula_selection"
)
# Short answer with some but not all rubric keywords
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"
)
# No rubric keywords present at all
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"
)