Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog.d/629-cat-item-information-rust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Rust-owned CAT item information

## Changed

- Routed public CAT `item_information()` probability and Fisher-information arithmetic through the existing compiled Rust bank-information kernel while keeping Python limited to bounded validation, immutable marshalling, and result transport.
- Preserved the existing simple-structure MIRT/MLS2PLM-family semantics and population-mean latent-position convention; the final global CAT item-selection policy remains a separate #629 ownership slice rather than being silently replaced by a different Rust adaptive policy.
66 changes: 66 additions & 0 deletions docs/doctoring/cat-rust-item-information.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CAT Rust-owned item-information doctoring

## Status and scope

This record governs the public `item_information()` boundary in
`python/fast_mlsirm/test_design.py`. The public function keeps Python-side shape,
factor, person/theta, and immutability validation, but the probability and
Fisher-information arithmetic is owned by the compiled Rust scoring core through
`_core.bank_information` / `mlsirm_core::scoring::bank_information_device`.

The supported contract is the existing simple-structure CAT item-information
surface: each item maps to one trait through `factor_id`, and the current
MIRT/MLS2PLM-family predictor semantics and population-mean latent-position
convention are preserved. This change moves numerical ownership without changing
the estimand.

## Numerical and ownership contract

For dichotomous items, the item-information quantity remains the ordinary Fisher
information for the active trait at the requested ability point. Python does not
recompute probabilities or `a^2 P(1-P)` on the production path. It validates and
marshals contiguous immutable inputs, calls the compiled core, validates the
returned shape, and transports the Rust-owned item-information vector.

Missing or incompatible compiled capability fails closed rather than silently
selecting a second Python numerical implementation. The final global
administered-item masking and deterministic maximum policy in `select_cat_item()`
remains a separate ownership slice because the existing Rust `cat_next_item`
implements a different adaptive policy; substituting it here would change public
semantics.

## Verification boundary

The ownership regression replaces `_core.bank_information` with an unmistakable
sentinel and proves that the public function returns the Rust-provided vector
exactly. A separate immutability regression proves caller-supplied `theta` and
`factor_id` arrays are not modified by marshalling. Full package CI also exercises
the existing CAT probability/information and adaptive-test behavior against the
same compiled scoring implementation used by serving.

This evidence establishes one production numerical owner for the public
item-information vector. It does not establish construct validity, fairness,
consequential-decision readiness, or that the remaining final CAT selection and
fixed-form assembly policies are Rust-owned.

## Rollback and follow-up

If the compiled bank-information path is defective, revert to the last verified
Rust implementation or disable the affected public boundary; do not restore a
silent Python probability/information fallback. Issue #629 remains the governing
follow-up for the final global CAT next-item policy and fixed-form assembly
numerical ownership. A future change must preserve the public selection estimand
or document and validate a deliberately new adaptive policy.

## References

Baker, F. B., & Kim, S.-H. (2017). *The basics of item response theory using R*.
Springer. https://doi.org/10.1007/978-3-319-54205-8

Lord, F. M. (1980). *Applications of item response theory to practical testing
problems*. Lawrence Erlbaum Associates.

van der Linden, W. J., & Pashley, P. J. (2010). Item selection and ability
estimation in adaptive testing. In W. J. van der Linden & C. A. W. Glas (Eds.),
*Elements of adaptive testing* (pp. 3–30). Springer.
https://doi.org/10.1007/978-0-387-85461-8_1
97 changes: 85 additions & 12 deletions python/fast_mlsirm/test_design.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import numpy as np

from .diagnostics import predict_proba
from .types import MLSIRMParams

_ITEM_INFORMATION_EPS_DISTANCE = 1e-8


def item_information(
params: MLSIRMParams,
Expand All @@ -13,13 +14,47 @@ def item_information(
person_index: int | None = None,
model: str = "MLS2PLM",
) -> np.ndarray:
"""Dichotomous item information for the simple-structure trait dimension."""
"""Dichotomous item information from the compiled Rust scoring core.

Python validates the public simple-structure request, selects the requested
person/trait point, and marshals immutable arrays. The probability and
Fisher-information arithmetic is owned by ``mlsirm-core`` through the same
bank-information entrypoint used by serving.
"""
factors = np.asarray(factor_id, dtype=np.int64)
if factors.shape != params.alpha.shape:
raise ValueError("factor_id length must match number of items")
sub = _person_params(params, theta=theta, person_index=person_index)
prob = predict_proba(sub, factors, model=model)[0]
return params.a * params.a * prob * (1.0 - prob)

from . import _core as core

theta_row = np.ascontiguousarray(sub.theta, dtype=np.float64).reshape(-1)
xi_row = np.ascontiguousarray(sub.xi, dtype=np.float64).reshape(-1)
alpha = np.ascontiguousarray(params.alpha, dtype=np.float64)
intercept = np.ascontiguousarray(params.b, dtype=np.float64)
zeta = np.ascontiguousarray(params.zeta, dtype=np.float64)
factor_values = np.ascontiguousarray(factors, dtype=np.int64)
result = dict(
core.bank_information(
theta_row,
xi_row,
1,
alpha=alpha,
b=intercept,
zeta=zeta.reshape(-1),
tau=float(params.tau),
factor_id=factor_values,
model=model,
n_dims=int(sub.theta.shape[1]),
latent_dim=int(sub.xi.shape[1]),
eps_distance=_ITEM_INFORMATION_EPS_DISTANCE,
device="auto",
)
)
information = np.asarray(result["item_info"], dtype=np.float64)
if information.shape != params.alpha.shape:
raise RuntimeError("compiled Rust core returned an invalid item-information shape")
return information


def select_cat_item(
Expand All @@ -34,8 +69,16 @@ def select_cat_item(

Returns the index of the not-yet-``administered`` item with the highest
item information at the given ``theta`` (or person), for adaptive testing.
Item-information arithmetic is Rust-owned; the final global maximum policy
remains Python-owned until the dedicated #629 selection slice lands.
"""
information = item_information(params, factor_id, theta=theta, person_index=person_index, model=model)
information = item_information(
params,
factor_id,
theta=theta,
person_index=person_index,
model=model,
)
candidates = information.copy()
if administered is not None:
used = np.asarray(administered, dtype=np.int64)
Expand Down Expand Up @@ -75,10 +118,18 @@ def assemble_test_form(
if labels is not None and labels.shape != scores.shape:
raise ValueError("content length must match information")

excluded = set(np.asarray(exclude, dtype=np.int64).tolist()) if exclude is not None else set()
excluded = (
set(np.asarray(exclude, dtype=np.int64).tolist())
if exclude is not None
else set()
)
selected: list[int] = []
counts: dict[str, int] = {}
order = [int(i) for i in np.argsort(-scores) if i not in excluded and np.isfinite(scores[i])]
order = [
int(i)
for i in np.argsort(-scores)
if i not in excluded and np.isfinite(scores[i])
]

for _ in range(length):
for item in order:
Expand All @@ -90,7 +141,16 @@ def assemble_test_form(
if next_counts.get(label, 0) >= max_counts.get(label, length):
continue
next_counts[label] = next_counts.get(label, 0) + 1
if _constraints_feasible(order, selected + [item], excluded, labels, next_counts, length, min_counts, max_counts):
if _constraints_feasible(
order,
selected + [item],
excluded,
labels,
next_counts,
length,
min_counts,
max_counts,
):
selected.append(item)
counts = next_counts
break
Expand All @@ -102,11 +162,17 @@ def assemble_test_form(
# Unreachable: the per-slot feasibility look-ahead only admits a pick
# when every minimum can still be met, so no completed length-form can
# leave a minimum unsatisfied here. Kept as a defensive guard.
raise ValueError(f"minimum content constraint not met: {label}") # pragma: no cover
raise ValueError(
f"minimum content constraint not met: {label}"
) # pragma: no cover
return np.asarray(selected, dtype=np.int64)


def _person_params(params: MLSIRMParams, theta: np.ndarray | None, person_index: int | None) -> MLSIRMParams:
def _person_params(
params: MLSIRMParams,
theta: np.ndarray | None,
person_index: int | None,
) -> MLSIRMParams:
"""Return a single-person parameter view for item-information evaluation.

When ``theta`` is given it is used directly, and the latent-space position
Expand All @@ -121,7 +187,11 @@ def _person_params(params: MLSIRMParams, theta: np.ndarray | None, person_index:
xi_row = params.xi[[person_index]]
else:
theta_row = np.asarray(theta, dtype=np.float64).reshape(1, -1)
xi_row = params.xi[[person_index]] if person_index is not None else params.xi.mean(axis=0, keepdims=True)
xi_row = (
params.xi[[person_index]]
if person_index is not None
else params.xi.mean(axis=0, keepdims=True)
)
if theta_row.shape[1] != params.theta.shape[1]:
raise ValueError("theta dimensionality must match params")
return MLSIRMParams(
Expand Down Expand Up @@ -150,7 +220,10 @@ def _constraints_feasible(
enough eligible items remain per content area to meet each minimum.
"""
slots_left = length - len(selected)
required_left = sum(max(0, minimum - counts.get(label, 0)) for label, minimum in min_counts.items())
required_left = sum(
max(0, minimum - counts.get(label, 0))
for label, minimum in min_counts.items()
)
if required_left > slots_left:
return False
if labels is None:
Expand Down
68 changes: 68 additions & 0 deletions tests/test_cat_item_information_rust_ownership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Ownership contracts for public CAT item information."""

from __future__ import annotations

import numpy as np

import fast_mlsirm._core as core
from fast_mlsirm.test_design import item_information
from fast_mlsirm.types import MLSIRMParams


def _bank() -> tuple[MLSIRMParams, np.ndarray]:
"""Return a small one-dimensional calibrated bank."""
discrimination = np.array([0.8, 1.1, 1.4, 1.7], dtype=np.float64)
bank = MLSIRMParams(
theta=np.array([[0.0]], dtype=np.float64),
alpha=np.log(discrimination),
b=np.array([-1.0, -0.2, 0.4, 1.2], dtype=np.float64),
xi=np.zeros((1, 1), dtype=np.float64),
zeta=np.zeros((4, 1), dtype=np.float64),
tau=-30.0,
)
return bank, np.zeros(4, dtype=np.int64)


def test_public_item_information_delegates_to_rust(monkeypatch) -> None:
"""The public information vector must be transported from the Rust owner."""
bank, factor_id = _bank()
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []

def fake_information(*args, **kwargs):
calls.append((args, kwargs))
return {
"item_info": [0.125, 0.25, 0.5, 0.75],
"test_info": [1.625],
}

monkeypatch.setattr(core, "bank_information", fake_information)
result = item_information(
bank,
factor_id,
theta=np.array([0.25], dtype=np.float64),
model="MIRT",
)

assert len(calls) == 1
assert np.array_equal(result, np.array([0.125, 0.25, 0.5, 0.75]))
args, kwargs = calls[0]
assert int(args[2]) == 1
assert kwargs["model"] == "MIRT"
assert kwargs["device"] == "auto"


def test_public_item_information_preserves_inputs(monkeypatch) -> None:
"""Marshalling into the Rust owner must not mutate caller arrays."""
bank, factor_id = _bank()
theta = np.array([-0.5], dtype=np.float64)
theta_before = theta.copy()
factor_before = factor_id.copy()

def fake_information(*args, **kwargs):
return {"item_info": [1.0, 2.0, 3.0, 4.0], "test_info": [10.0]}

monkeypatch.setattr(core, "bank_information", fake_information)
item_information(bank, factor_id, theta=theta, model="MIRT")

assert np.array_equal(theta, theta_before)
assert np.array_equal(factor_id, factor_before)
Loading