813bd586d6
v0.3 milestone merged to main. Mastery scoring + competency rubrics + verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered. Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres). ---ci--- project: praxis phase: 2 milestone: v0.3 status: complete milestone_complete: true milestone_merged_to_main: true ---/ci---
98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
"""IRT engine — 1PL/Rasch with Bayesian theta update (SLICE-04, REQ-NFR-IRT-01).
|
|
|
|
P_success(theta, b) = logistic(theta - b) = 1 / (1 + exp(-(theta - b))).
|
|
update_theta uses a Gaussian-approximation Bayesian update (Kalman-like):
|
|
the posterior precision is the prior precision plus the Fisher information
|
|
P*(1-P), and the posterior mean shifts toward the outcome by the Kalman gain.
|
|
|
|
Cold-start (R-IRT-01): theta=0, sigma_sq=1; until >=5 observations, scenario
|
|
selection falls back to difficulty-based matching (difficulty closest to
|
|
round(theta + logit(target_p))).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from server.scenarios.library import ScenarioLibrary
|
|
from server.scenarios.schema import Scenario
|
|
|
|
COLD_START_MIN_OBSERVATIONS = 5
|
|
DEFAULT_THETA = 0.0
|
|
DEFAULT_SIGMA_SQ = 1.0
|
|
|
|
|
|
def _logit(p: float) -> float:
|
|
return math.log(p / (1.0 - p))
|
|
|
|
|
|
class IRTEngine:
|
|
"""1PL/Rasch IRT with Gaussian-approximation Bayesian theta updates."""
|
|
|
|
@staticmethod
|
|
def P_success(theta: float, b: float) -> float:
|
|
exp_neg = math.exp(-(theta - b))
|
|
return 1.0 / (1.0 + exp_neg)
|
|
|
|
@staticmethod
|
|
def update_theta(
|
|
theta: float, sigma_sq: float, outcome: float, b: float
|
|
) -> tuple[float, float]:
|
|
"""Bayesian update of theta given a binary (0/1) outcome.
|
|
|
|
Uses the standard 1PL Gaussian-approximation (Kalman-like) update:
|
|
P = P_success(theta, b)
|
|
new_precision = 1/sigma_sq + P*(1-P)
|
|
new_sigma_sq = 1 / new_precision
|
|
new_theta = theta + new_sigma_sq * (outcome - P)
|
|
"""
|
|
p = IRTEngine.P_success(theta, b)
|
|
prior_precision = 1.0 / sigma_sq
|
|
info = p * (1.0 - p)
|
|
new_precision = prior_precision + info
|
|
new_sigma_sq = 1.0 / new_precision
|
|
new_theta = theta + new_sigma_sq * (outcome - p)
|
|
return new_theta, new_sigma_sq
|
|
|
|
@staticmethod
|
|
def select_scenario(
|
|
theta: float,
|
|
library: ScenarioLibrary,
|
|
path: str,
|
|
target_p: float = 0.7,
|
|
observations: int = 0,
|
|
) -> Scenario | None:
|
|
"""Select the next scenario for a learner.
|
|
|
|
If observations < COLD_START_MIN_OBSERVATIONS (R-IRT-01), fall back to
|
|
difficulty-based selection: pick the scenario whose `difficulty` is
|
|
closest to round(theta + logit(target_p)).
|
|
|
|
Otherwise delegate to library.select_for_theta (IRT-aware selection
|
|
targeting ~target_p).
|
|
"""
|
|
if observations < COLD_START_MIN_OBSERVATIONS:
|
|
entries = library.list_by_path(path)
|
|
if not entries:
|
|
return None
|
|
target_difficulty = round(theta + _logit(target_p))
|
|
target_difficulty = max(1, min(5, target_difficulty))
|
|
best_entry = None
|
|
best_dist = math.inf
|
|
for e in entries:
|
|
dist = abs(e.difficulty - target_difficulty)
|
|
if dist < best_dist:
|
|
best_dist = dist
|
|
best_entry = e
|
|
if best_entry is None:
|
|
return None
|
|
return library.get(best_entry.id)
|
|
return library.select_for_theta(theta, path, target_p=target_p)
|
|
|
|
|
|
__all__ = [
|
|
"IRTEngine",
|
|
"COLD_START_MIN_OBSERVATIONS",
|
|
"DEFAULT_THETA",
|
|
"DEFAULT_SIGMA_SQ",
|
|
] |