diff --git a/docs/changelog.d/629-cat-item-information-rust.md b/docs/changelog.d/629-cat-item-information-rust.md new file mode 100644 index 000000000..9cd533fed --- /dev/null +++ b/docs/changelog.d/629-cat-item-information-rust.md @@ -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. diff --git a/docs/doctoring/cat-rust-item-information.md b/docs/doctoring/cat-rust-item-information.md new file mode 100644 index 000000000..7f5fd3182 --- /dev/null +++ b/docs/doctoring/cat-rust-item-information.md @@ -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 diff --git a/python/fast_mlsirm/test_design.py b/python/fast_mlsirm/test_design.py index 6e43c7536..d99f96c97 100644 --- a/python/fast_mlsirm/test_design.py +++ b/python/fast_mlsirm/test_design.py @@ -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, @@ -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( @@ -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) @@ -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: @@ -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 @@ -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 @@ -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( @@ -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: diff --git a/tests/test_cat_item_information_rust_ownership.py b/tests/test_cat_item_information_rust_ownership.py new file mode 100644 index 000000000..1b2290e8a --- /dev/null +++ b/tests/test_cat_item_information_rust_ownership.py @@ -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)