Files
praxis/server/mastery/irt.py
T
Praxis CI 813bd586d6 docs(milestone): merge v0.3-mastery-scoring → main
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---
2026-08-04 00:14:59 +00:00

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",
]