Files
praxis/tests/test_irt_selection_integration.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

243 lines
7.7 KiB
Python

"""SLICE-07 TASK-07-04 — IRT selection integration (next-scenario recommendation).
Verifies that `library.select_for_theta` + `irt.select_scenario` pick the right
scenario for a given (theta, path) pair. Tests both cold-start
(observations < 5 → difficulty-based) and warm-start (>= 5 → theta-based)
selection paths against the real scenario library + index.
"""
from __future__ import annotations
import math
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from server.mastery.irt import (
COLD_START_MIN_OBSERVATIONS,
DEFAULT_THETA,
IRTEngine,
)
from server.scenarios.library import ScenarioLibrary
from server.scenarios.schema import Scenario
_SCENARIOS_DIR = Path(__file__).resolve().parent.parent / "scenarios"
def _library() -> ScenarioLibrary:
return ScenarioLibrary(scenarios_dir=_SCENARIOS_DIR)
def _logit(p: float) -> float:
return math.log(p / (1.0 - p))
# ── warm-start: delegates to library.select_for_theta ─────────────────────────
def test_warm_start_selects_scenario_near_target_p():
library = _library()
theta = 1.0
target_p = 0.7
selected = IRTEngine.select_scenario(
theta=theta,
library=library,
path="customer_service",
target_p=target_p,
observations=COLD_START_MIN_OBSERVATIONS,
)
assert selected is not None
assert isinstance(selected, Scenario)
# The selected scenario's difficulty should be the closest to theta - logit(p).
entries = library.list_by_path("customer_service")
target_b = theta - _logit(target_p)
best_id = min(entries, key=lambda e: abs(float(e.difficulty) - target_b)).id
assert selected.id == best_id
def test_warm_start_low_theta_picks_easiest():
library = _library()
selected = IRTEngine.select_scenario(
theta=-3.0,
library=library,
path="customer_service",
target_p=0.7,
observations=10,
)
assert selected is not None
entries = library.list_by_path("customer_service")
easiest = min(entries, key=lambda e: e.difficulty)
assert selected.id == easiest.id
def test_warm_start_high_theta_picks_hardest():
library = _library()
selected = IRTEngine.select_scenario(
theta=10.0,
library=library,
path="customer_service",
target_p=0.7,
observations=10,
)
assert selected is not None
entries = library.list_by_path("customer_service")
hardest = max(entries, key=lambda e: e.difficulty)
assert selected.id == hardest.id
def test_warm_start_target_p_half_uses_theta_directly():
library = _library()
theta = 3.0
selected = IRTEngine.select_scenario(
theta=theta,
library=library,
path="customer_service",
target_p=0.5,
observations=COLD_START_MIN_OBSERVATIONS,
)
assert selected is not None
# logit(0.5) == 0 → target_b == theta.
entries = library.list_by_path("customer_service")
best_id = min(entries, key=lambda e: abs(float(e.difficulty) - theta)).id
assert selected.id == best_id
# ── cold-start: difficulty-based fallback (observations < 5) ──────────────────
def test_cold_start_uses_difficulty_not_theta_based_selection():
library = _library()
theta = 2.0
target_p = 0.7
cold = IRTEngine.select_scenario(
theta=theta,
library=library,
path="customer_service",
target_p=target_p,
observations=COLD_START_MIN_OBSERVATIONS - 1,
)
# Cold-start target difficulty = clamp(round(theta + logit(target_p)), 1, 5).
target_difficulty = max(1, min(5, round(theta + _logit(target_p))))
entries = library.list_by_path("customer_service")
expected = min(entries, key=lambda e: abs(e.difficulty - target_difficulty))
assert cold is not None
assert cold.id == expected.id
def test_cold_start_boundary_observations_just_below_threshold():
library = _library()
selected = IRTEngine.select_scenario(
theta=0.0,
library=library,
path="customer_service",
target_p=0.7,
observations=COLD_START_MIN_OBSERVATIONS - 1,
)
assert selected is not None
# At theta=0 + logit(0.7) ≈ 0.847 → round → 1 → easiest scenario.
entries = library.list_by_path("customer_service")
easiest = min(entries, key=lambda e: e.difficulty)
assert selected.id == easiest.id
def test_cold_start_at_threshold_switches_to_warm():
"""At exactly COLD_START_MIN_OBSERVATIONS, warm-start takes over."""
library = _library()
theta = 1.5
selected_warm = IRTEngine.select_scenario(
theta=theta,
library=library,
path="customer_service",
target_p=0.7,
observations=COLD_START_MIN_OBSERVATIONS,
)
# Compare against the warm-start selection directly.
expected = library.select_for_theta(theta, "customer_service", target_p=0.7)
assert selected_warm is not None
assert expected is not None
assert selected_warm.id == expected.id
def test_cold_start_clamps_high_theta_to_hardest():
library = _library()
selected = IRTEngine.select_scenario(
theta=10.0,
library=library,
path="customer_service",
target_p=0.7,
observations=0,
)
assert selected is not None
entries = library.list_by_path("customer_service")
hardest = max(entries, key=lambda e: e.difficulty)
assert selected.id == hardest.id
def test_cold_start_clamps_low_theta_to_easiest():
library = _library()
selected = IRTEngine.select_scenario(
theta=-10.0,
library=library,
path="customer_service",
target_p=0.7,
observations=2,
)
assert selected is not None
entries = library.list_by_path("customer_service")
easiest = min(entries, key=lambda e: e.difficulty)
assert selected.id == easiest.id
# ── empty-path guard ───────────────────────────────────────────────────────────
def test_select_returns_none_for_unknown_path_warm_start():
library = _library()
selected = IRTEngine.select_scenario(
theta=1.0,
library=library,
path="nonexistent_path",
target_p=0.7,
observations=10,
)
assert selected is None
def test_select_returns_none_for_unknown_path_cold_start():
library = _library()
selected = IRTEngine.select_scenario(
theta=1.0,
library=library,
path="nonexistent_path",
target_p=0.7,
observations=0,
)
assert selected is None
# ── library.select_for_theta direct contract ──────────────────────────────────
def test_library_select_for_theta_targets_predicted_p():
library = _library()
theta = 0.0
target_p = 0.7
selected = library.select_for_theta(theta, "customer_service", target_p=target_p)
assert selected is not None
# Predicted P for the selected scenario's difficulty should be the closest
# to target_p among all scenarios in the path.
entries = library.list_by_path("customer_service")
predicted = {
e.id: IRTEngine.P_success(theta, float(e.difficulty)) for e in entries
}
closest = min(predicted, key=lambda sid: abs(predicted[sid] - target_p))
assert selected.id == closest
def test_library_select_for_theta_is_deterministic():
library = _library()
a = library.select_for_theta(1.2, "customer_service", target_p=0.7)
b = library.select_for_theta(1.2, "customer_service", target_p=0.7)
assert a is not None and b is not None
assert a.id == b.id