Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

290 lines
11 KiB
Python

"""Mastery Path policy — pure decisions over a :class:`LearningProgress`.
No LLM calls, no I/O. This is the engine the chat-loop tutor consults each
turn. It answers three questions:
* **is this objective mastered?** (:func:`is_mastered` — a HARD, per-type gate)
* **what should the learner work on next?** (:func:`next_objective`)
* **what does the whole map look like?** (:func:`map_summary`)
The gate is the heart of mastery-based learning. An objective only counts as
mastered when the evidence clears its threshold, and :func:`next_objective`
keeps returning the same objective until it does — advancement is *computed
from what is mastered*, never tracked by a stage counter. Objective ordering
follows module order then knowledge-point order; an objective the learner has
already proven is skipped (the "test out" / compression path) because the gate
reads proven mastery, not a fixed sequence of stages.
"""
from __future__ import annotations
from dataclasses import dataclass
import time
from deeptutor.learning.models import (
KnowledgePoint,
KnowledgeType,
LearningProgress,
ReviewTask,
)
# Quantitative gate for objective knowledge types: the learner must reach this
# mastery (recency-weighted accuracy; see ``mastery.compute_mastery``) before
# the objective unlocks. ~0.9 mirrors Alpha School's "90% before you advance".
QUANTITATIVE_GATE: dict[KnowledgeType, float] = {
KnowledgeType.MEMORY: 0.9,
KnowledgeType.PROCEDURE: 0.9,
}
# CONCEPT / DESIGN are gated qualitatively — a Feynman-style explanation judged
# by the tutor via ``mastery_assess`` — rather than by string-graded accuracy,
# because there is rarely a single canonical right answer to match against.
QUALITATIVE_TYPES: frozenset[KnowledgeType] = frozenset(
{KnowledgeType.CONCEPT, KnowledgeType.DESIGN}
)
# Display mastery a qualitative pass maps to, so the map's colours agree with
# the gate even though qualitative mastery is a boolean, not a score. (The
# fail-side display is handled in ``LearningService.record_qualitative``.)
_QUALITATIVE_PASS_DISPLAY = 1.0
def gate_threshold(kp_type: KnowledgeType) -> float:
"""The quantitative mastery bar for *kp_type* (qualitative types report
their pass-display value so callers have a single number to show)."""
if kp_type in QUALITATIVE_TYPES:
return _QUALITATIVE_PASS_DISPLAY
return QUANTITATIVE_GATE.get(kp_type, 0.9)
def is_mastered(progress: LearningProgress, kp: KnowledgePoint) -> bool:
"""Whether ``kp`` clears its mastery gate.
* MEMORY / PROCEDURE: recency-weighted accuracy ≥ the type's threshold.
* CONCEPT / DESIGN: a recorded qualitative pass (``mastery_assess``).
"""
if kp.type in QUALITATIVE_TYPES:
return bool(progress.qualitative_mastery.get(kp.id, False))
return progress.mastery_levels.get(kp.id, 0.0) >= gate_threshold(kp.type)
def display_mastery(progress: LearningProgress, kp: KnowledgePoint) -> float:
"""A 0..1 number for the map UI. Qualitatively-mastered points show full;
otherwise the recency-weighted accuracy stands in."""
if kp.type in QUALITATIVE_TYPES and progress.qualitative_mastery.get(kp.id):
return _QUALITATIVE_PASS_DISPLAY
return float(progress.mastery_levels.get(kp.id, 0.0))
def objective_status(progress: LearningProgress, kp: KnowledgePoint) -> str:
"""``"mastered"`` | ``"learning"`` | ``"new"`` for one knowledge point."""
if is_mastered(progress, kp):
return "mastered"
seen = any(a.knowledge_point_id == kp.id for a in progress.quiz_attempts) or (
kp.id in progress.qualitative_mastery
)
return "learning" if seen else "new"
def due_reviews(progress: LearningProgress, *, now: float | None = None) -> list[ReviewTask]:
"""Spaced-repetition tasks whose ``due_at`` has passed, highest priority
first. Pure read over ``progress.review_queue`` (built by the scheduler)."""
moment = time.time() if now is None else now
due = [task for task in progress.review_queue if task.due_at <= moment]
due.sort(key=lambda task: task.priority)
return due
@dataclass(frozen=True)
class NextStep:
"""What the tutor should do next, decided by the gate — not a stage cursor.
``action`` is advisory for the model's pedagogy; the binding fact is the
objective and whether it is mastered. Values:
* ``answer_pending`` — a posed question awaits the learner's answer.
* ``review`` — a spaced-repetition item is due.
* ``probe`` — an untouched objective; test out before teaching.
* ``practice`` — a quantitative objective below its gate.
* ``assess`` — a qualitative objective awaiting a Feynman-style check.
* ``complete`` — every objective mastered, nothing due.
"""
action: str
module_id: str = ""
module_name: str = ""
knowledge_point_id: str = ""
knowledge_point_name: str = ""
knowledge_point_type: str = ""
status: str = ""
gate: str = ""
mastery: float = 0.0
threshold: float = 0.0
reason: str = ""
pending_prompt: str = ""
def to_dict(self) -> dict:
return {
"action": self.action,
"module_id": self.module_id,
"module_name": self.module_name,
"knowledge_point_id": self.knowledge_point_id,
"knowledge_point_name": self.knowledge_point_name,
"knowledge_point_type": self.knowledge_point_type,
"status": self.status,
"gate": self.gate,
"mastery": round(self.mastery, 3),
"threshold": round(self.threshold, 3),
"reason": self.reason,
"pending_prompt": self.pending_prompt,
}
def find_knowledge_point(
progress: LearningProgress, kp_id: str
) -> tuple[KnowledgePoint | None, str, str]:
"""Return ``(kp, module_id, module_name)`` for *kp_id*, or ``(None, "", "")``."""
for module in progress.modules:
for kp in module.knowledge_points:
if kp.id == kp_id:
return kp, module.id, module.name
return None, "", ""
def _gate_kind(kp: KnowledgePoint) -> str:
return "qualitative" if kp.type in QUALITATIVE_TYPES else "quantitative"
def next_objective(progress: LearningProgress, *, now: float | None = None) -> NextStep:
"""Decide the next thing to work on. Order of precedence:
1. an outstanding posed question (grade it before moving on);
2. a due spaced-repetition review (don't let mastered ground decay);
3. the first not-yet-mastered objective in module/KP order (the gate IS
the cursor — mastered objectives are skipped);
4. otherwise the path is complete.
"""
pending = progress.pending_question
if pending is not None:
kp, module_id, module_name = find_knowledge_point(progress, pending.knowledge_point_id)
return NextStep(
action="answer_pending",
module_id=module_id or pending.module_id,
module_name=module_name,
knowledge_point_id=pending.knowledge_point_id,
knowledge_point_name=kp.name if kp else "",
knowledge_point_type=kp.type.value if kp else "",
status=objective_status(progress, kp) if kp else "learning",
gate=_gate_kind(kp) if kp else "",
mastery=display_mastery(progress, kp) if kp else 0.0,
threshold=gate_threshold(kp.type) if kp else 0.0,
reason="A posed question is awaiting the learner's answer; grade it with mastery_grade.",
pending_prompt=pending.prompt,
)
due = due_reviews(progress, now=now)
if due:
kp, module_id, module_name = find_knowledge_point(progress, due[0].knowledge_point_id)
if kp is not None:
return NextStep(
action="review",
module_id=module_id,
module_name=module_name,
knowledge_point_id=kp.id,
knowledge_point_name=kp.name,
knowledge_point_type=kp.type.value,
status=objective_status(progress, kp),
gate=_gate_kind(kp),
mastery=display_mastery(progress, kp),
threshold=gate_threshold(kp.type),
reason="This objective is due for spaced-repetition review.",
)
for module in sorted(progress.modules, key=lambda m: m.order):
for kp in module.knowledge_points:
if is_mastered(progress, kp):
continue
status = objective_status(progress, kp)
gate = _gate_kind(kp)
if status == "new":
action = "probe"
elif gate == "qualitative":
action = "assess"
else:
action = "practice"
return NextStep(
action=action,
module_id=module.id,
module_name=module.name,
knowledge_point_id=kp.id,
knowledge_point_name=kp.name,
knowledge_point_type=kp.type.value,
status=status,
gate=gate,
mastery=display_mastery(progress, kp),
threshold=gate_threshold(kp.type),
reason=(
"Untouched objective — probe first to let the learner test out."
if status == "new"
else "Objective is below its mastery gate; keep working it until it clears."
),
)
return NextStep(action="complete", reason="All objectives are mastered and no reviews are due.")
def map_summary(progress: LearningProgress, *, now: float | None = None) -> dict:
"""A compact, render-ready snapshot of the whole path for the tutor's
``mastery_status`` tool and the dashboard."""
counts = {"mastered": 0, "learning": 0, "new": 0, "total": 0}
modules_out: list[dict] = []
for module in sorted(progress.modules, key=lambda m: m.order):
kps_out: list[dict] = []
mastered = 0
for kp in module.knowledge_points:
status = objective_status(progress, kp)
counts[status] += 1
counts["total"] += 1
if status == "mastered":
mastered += 1
kps_out.append(
{
"id": kp.id,
"name": kp.name,
"type": kp.type.value,
"status": status,
"mastery": round(display_mastery(progress, kp), 3),
}
)
modules_out.append(
{
"id": module.id,
"name": module.name,
"order": module.order,
"mastered": mastered,
"total": len(module.knowledge_points),
"knowledge_points": kps_out,
}
)
return {
"counts": counts,
"due_reviews": len(due_reviews(progress, now=now)),
"complete": counts["total"] > 0 and counts["mastered"] == counts["total"],
"modules": modules_out,
}
__all__ = [
"QUANTITATIVE_GATE",
"QUALITATIVE_TYPES",
"NextStep",
"gate_threshold",
"is_mastered",
"display_mastery",
"objective_status",
"due_reviews",
"find_knowledge_point",
"next_objective",
"map_summary",
]