diff --git a/docs/changelog.d/974-utility-control-boundary.md b/docs/changelog.d/974-utility-control-boundary.md new file mode 100644 index 000000000..452ce8a02 --- /dev/null +++ b/docs/changelog.d/974-utility-control-boundary.md @@ -0,0 +1,6 @@ +# Selection utility numeric trust boundary + +## Fixed + +- Hardened classical selection-utility and Taylor-Russell scalar controls so booleans, non-real objects, and non-finite values fail with package-owned validation before compiled Rust discovery. +- Prevented arbitrary caller-defined `__float__` callbacks from executing during public control marshalling while preserving genuine Python/NumPy real scalar compatibility and keeping all BCG, Naylor-Shine, and Taylor-Russell arithmetic Rust-owned. diff --git a/python/fast_mlsirm/utility.py b/python/fast_mlsirm/utility.py index c4152203b..6aa2834e5 100644 --- a/python/fast_mlsirm/utility.py +++ b/python/fast_mlsirm/utility.py @@ -36,8 +36,29 @@ from __future__ import annotations +import math from dataclasses import dataclass +import numpy as np + + +_TRUSTED_REAL_SCALAR_TYPES = ( + int, + float, + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.float16, + np.float32, + np.float64, + np.longdouble, +) + @dataclass class SelectionUtilityResult: @@ -46,7 +67,7 @@ class SelectionUtilityResult: ``xc`` is the standard-normal predictor cutoff ``Phi^-1(1 - sr)``; ``ux`` the selection intensity ``phi(xc)/sr`` (mean standardized predictor of those selected); ``pux = rxy * ux`` the Naylor-Shine mean - standardized criterion of those selected; ``utility_gain`` the BCG + standardized criterion of those selected); ``utility_gain`` the BCG gain ``n * period * sdy * pux - cost_total``.""" xc: float @@ -67,6 +88,16 @@ class TaylorRussellResult: q_joint: float +def _coerce_finite_real(value: object, *, name: str) -> float: + """Return a trusted finite real scalar without invoking caller protocols.""" + if type(value) not in _TRUSTED_REAL_SCALAR_TYPES: + raise ValueError(f"{name} must be a finite real number") + marshaled = float(value) + if not math.isfinite(marshaled): + raise ValueError(f"{name} must be a finite real number") + return marshaled + + def selection_utility( n: float, sdy: float, @@ -84,10 +115,22 @@ def selection_utility( applicant" but never multiplies by ``n``; we document the actual semantics), ``period`` expected tenure (>= 1). """ + marshaled_n = _coerce_finite_real(n, name="n") + marshaled_sdy = _coerce_finite_real(sdy, name="sdy") + marshaled_rxy = _coerce_finite_real(rxy, name="rxy") + marshaled_sr = _coerce_finite_real(sr, name="sr") + marshaled_cost_total = _coerce_finite_real(cost_total, name="cost_total") + marshaled_period = _coerce_finite_real(period, name="period") + from . import _core r = _core.selection_utility( - float(n), float(sdy), float(rxy), float(sr), float(cost_total), float(period) + marshaled_n, + marshaled_sdy, + marshaled_rxy, + marshaled_sr, + marshaled_cost_total, + marshaled_period, ) return SelectionUtilityResult( xc=r["xc"], ux=r["ux"], pux=r["pux"], utility_gain=r["utility_gain"] @@ -101,9 +144,13 @@ def taylor_russell(rxy: float, sr: float, br: float) -> TaylorRussellResult: base rate of success in (0, 1). At ``rxy = 0`` the success ratio equals ``br`` (no selection information). """ + marshaled_rxy = _coerce_finite_real(rxy, name="rxy") + marshaled_sr = _coerce_finite_real(sr, name="sr") + marshaled_br = _coerce_finite_real(br, name="br") + from . import _core - r = _core.taylor_russell(float(rxy), float(sr), float(br)) + r = _core.taylor_russell(marshaled_rxy, marshaled_sr, marshaled_br) return TaylorRussellResult( success_ratio=r["success_ratio"], base_rate=r["base_rate"], diff --git a/tests/test_utility_control_boundary.py b/tests/test_utility_control_boundary.py new file mode 100644 index 000000000..23ce5fa97 --- /dev/null +++ b/tests/test_utility_control_boundary.py @@ -0,0 +1,93 @@ +"""Trust-boundary regressions for classical selection utility controls.""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from fast_mlsirm.utility import selection_utility, taylor_russell + + +class _HostileFloat: + """Arbitrary float-protocol object whose callback must stay unreachable.""" + + calls = 0 + + @classmethod + def reset(cls) -> None: + """Reset the callback counter.""" + cls.calls = 0 + + def __float__(self) -> float: + type(self).calls += 1 + raise AssertionError("caller __float__ callback must not execute") + + +class _HostileFloatSubclass(float): + """Float subclass whose conversion callback must stay unreachable.""" + + calls = 0 + + @classmethod + def reset(cls) -> None: + """Reset the callback counter.""" + cls.calls = 0 + + def __float__(self) -> float: + type(self).calls += 1 + raise AssertionError("caller float-subclass __float__ callback must not execute") + + +_UTILITY_CONTROL_CALLS = ( + lambda value: selection_utility(value, 1.0, 0.5, 0.5), + lambda value: selection_utility(1.0, value, 0.5, 0.5), + lambda value: selection_utility(1.0, 1.0, value, 0.5), + lambda value: selection_utility(1.0, 1.0, 0.5, value), + lambda value: selection_utility(1.0, 1.0, 0.5, 0.5, value, 1.0), + lambda value: selection_utility(1.0, 1.0, 0.5, 0.5, 0.0, value), + lambda value: taylor_russell(value, 0.5, 0.5), + lambda value: taylor_russell(0.4, value, 0.5), + lambda value: taylor_russell(0.4, 0.5, value), +) + + +@pytest.mark.parametrize("call", _UTILITY_CONTROL_CALLS) +@pytest.mark.parametrize("hostile_type", (_HostileFloat, _HostileFloatSubclass)) +def test_utility_rejects_float_protocol_objects_without_callbacks( + call, hostile_type +) -> None: + """Every scalar position rejects hostile conversion without callback execution.""" + hostile_type.reset() + + with pytest.raises(ValueError, match="must be a finite real number"): + call(hostile_type()) + + assert hostile_type.calls == 0 + + +@pytest.mark.parametrize("call", _UTILITY_CONTROL_CALLS) +@pytest.mark.parametrize("value", (True, False, math.inf, -math.inf, math.nan)) +def test_utility_rejects_boolean_and_nonfinite_controls(call, value) -> None: + """Every scalar position rejects boolean and non-finite control values.""" + with pytest.raises(ValueError, match="must be a finite real number"): + call(value) + + +def test_numpy_real_scalars_preserve_native_results() -> None: + """Trusted NumPy real scalars produce the same Rust-owned results as floats.""" + py_utility = selection_utility(10.0, 2.0, 0.4, 0.5, 1.0, 2.0) + np_utility = selection_utility( + np.float64(10.0), + np.float64(2.0), + np.float64(0.4), + np.float64(0.5), + np.float64(1.0), + np.float64(2.0), + ) + assert np_utility == py_utility + + py_tr = taylor_russell(0.4, 0.5, 0.6) + np_tr = taylor_russell(np.float64(0.4), np.float64(0.5), np.float64(0.6)) + assert np_tr == py_tr