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
7 changes: 7 additions & 0 deletions docs/changelog.d/872-config-integer-callback-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Harden configuration integer trust boundaries

## Fixed

- Reject caller-defined integer subclasses and arbitrary `__index__` providers before public simulation and fit configuration validation can dispatch caller-controlled coercion.
- Preserve exact built-in integers and genuine NumPy integer scalars while validating simulation size, optimizer-work, quadrature, and latent-integration controls through built-in integer values.
- Run the same simulation and fit validators at construction so memory-safety bounds cannot be bypassed by skipping an explicit `validate()` call.
17 changes: 17 additions & 0 deletions docs/doctoring/config_integer_callback_safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Configuration integer callback safety

## Problem

Public configuration validation accepted Python's generic integer protocol. Calling `operator.index()` or comparing caller-controlled integer-like objects before trust was established allowed arbitrary `__index__` implementations or integer subclasses to participate in validation.

## Boundary decision

Configuration validation is marshalling and trust-boundary work, not psychometric arithmetic. The package now accepts only exact built-in `int` values and exact supported NumPy integer scalar types for validated integer controls. Accepted NumPy scalars are converted to built-in integers for bounds and work-budget calculations; booleans, caller-defined `int` subclasses, and arbitrary index providers are rejected without invoking their coercion hooks.

`MLS2PLMConfig` and `FitConfig` run that same validator from `__post_init__`, so invalid or untrusted controls cannot exist as constructed objects. `validate()` remains public and idempotent for callers that already invoke it at simulate/fit entry points.

The hardened surface covers simulation sizes and latent dimension plus fit latent dimension, optimizer iteration/restart/history controls, quadrature node counts, marginal M-step count, and latent-space integration point/seed controls. Numerical model ownership and Rust-first computation are unchanged.

## Test evidence

`tests/test_config_integer_callback_safety.py` provides hostile `__index__` regressions, valid-valued caller `int` subclasses, and genuine NumPy scalar compatibility. The original RED commit is `4c81e4dc465312d13f044b9b47e14d839af6cc1a`; exact-head hosted CI/security/package/coverage/review evidence remains authoritative as the branch advances.
2 changes: 1 addition & 1 deletion fuzz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ codegraph explore "neg_loglik_and_grad config Params ModelConfig"
| --- | --- | --- |
| `load_factor_csv` | `python/fast_mlsirm/io.py` | Reads an on-disk item→factor CSV via `numpy.loadtxt`; reached from the CLI. |
| `render_diagnostics_report` | `python/fast_mlsirm/report.py` | Parses an **arbitrary JSON** diagnostics file and renders it to HTML. |
| `MLS2PLMConfig` / `FitConfig` `.validate()` | `python/fast_mlsirm/config.py` | Every CLI / API call funnels user numeric parameters through these validators. |
| `MLS2PLMConfig` / `FitConfig` construction and `.validate()` | `python/fast_mlsirm/config.py` | Every CLI / API call funnels user numeric parameters through these validators, including construction-time checks. |
| `neg_loglik_and_grad` | `crates/mlsirm-core/src/lib.rs` | The core numeric kernel — widest Rust blast radius; consumes response data + parameter vectors. |

## Tools & Licenses
Expand Down
57 changes: 29 additions & 28 deletions fuzz/atheris/fuzz_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

``MLS2PLMConfig`` and ``FitConfig`` are the request/DTO validators of the
package: every CLI invocation and every public API entry point funnels
user-supplied numeric parameters through ``.validate()``. The contract is that
``validate()`` is a *total* function on arbitrary field values -- it must
either return normally or raise ``ValueError`` with a message. Any other
exception (``TypeError`` from an unexpected type, ``OverflowError``,
``ZeroDivisionError`` in the equicorrelation bound, ...) is a bug.
user-supplied numeric parameters through construction-time ``.validate()``.
The contract is that construction and ``validate()`` are *total* on arbitrary
field values -- they must either return normally or raise ``ValueError`` with a
message. Any other exception (``TypeError`` from an unexpected type,
``OverflowError``, ``ZeroDivisionError`` in the equicorrelation bound, ...) is
a bug.

This harness draws arbitrary field values from the fuzzer, builds both configs,
and asserts the validator only ever rejects with ``ValueError``. When a config
Expand Down Expand Up @@ -44,38 +45,38 @@ def _maybe_nan_float(fdp: "atheris.FuzzedDataProvider") -> float:
def _test_one_input(data: bytes) -> None:
fdp = atheris.FuzzedDataProvider(data)

sim = MLS2PLMConfig(
n_persons=fdp.ConsumeIntInRange(-8, 4096),
n_dims=fdp.ConsumeIntInRange(-8, 512),
items_per_dim=fdp.ConsumeIntInRange(-8, 512),
latent_dim=fdp.ConsumeIntInRange(-8, 512),
phi=_maybe_nan_float(fdp),
gamma=_maybe_nan_float(fdp),
seed=fdp.ConsumeInt(8),
dtype=fdp.PickValueInList(["float64", "float32", "int8", "", "FLOAT64"]),
)
try:
sim = MLS2PLMConfig(
n_persons=fdp.ConsumeIntInRange(-8, 4096),
n_dims=fdp.ConsumeIntInRange(-8, 512),
items_per_dim=fdp.ConsumeIntInRange(-8, 512),
latent_dim=fdp.ConsumeIntInRange(-8, 512),
phi=_maybe_nan_float(fdp),
gamma=_maybe_nan_float(fdp),
seed=fdp.ConsumeInt(8),
dtype=fdp.PickValueInList(["float64", "float32", "int8", "", "FLOAT64"]),
)
sim.validate()
except ValueError:
pass
else:
# n_items is a pure product of two validated positive ints.
assert sim.n_items >= 1, f"validated config produced n_items={sim.n_items}"

fit = FitConfig(
model=fdp.PickValueInList(["MLS2PLM", "mls2plm", "MIRT", "bogus", ""]),
latent_dim=fdp.ConsumeIntInRange(-8, 512),
optimizer=fdp.PickValueInList(["adam", "lbfgs", "adam_lbfgs", "sgd", ""]),
max_iter=fdp.ConsumeIntInRange(-8, 100000),
n_restarts=fdp.ConsumeIntInRange(-8, 512),
learning_rate=_maybe_nan_float(fdp),
seed=fdp.ConsumeInt(8),
eps_distance=_maybe_nan_float(fdp),
init_gamma=_maybe_nan_float(fdp),
backend=fdp.PickValueInList(["numpy", "rust", "auto", "", "NUMPY"]),
penalty=PenaltyConfig(),
)
try:
fit = FitConfig(
model=fdp.PickValueInList(["MLS2PLM", "mls2plm", "MIRT", "bogus", ""]),
latent_dim=fdp.ConsumeIntInRange(-8, 512),
optimizer=fdp.PickValueInList(["adam", "lbfgs", "adam_lbfgs", "sgd", ""]),
max_iter=fdp.ConsumeIntInRange(-8, 100000),
n_restarts=fdp.ConsumeIntInRange(-8, 512),
learning_rate=_maybe_nan_float(fdp),
seed=fdp.ConsumeInt(8),
eps_distance=_maybe_nan_float(fdp),
init_gamma=_maybe_nan_float(fdp),
backend=fdp.PickValueInList(["numpy", "rust", "auto", "", "NUMPY"]),
penalty=PenaltyConfig(),
)
fit.validate()
except ValueError:
pass
Expand Down
127 changes: 79 additions & 48 deletions python/fast_mlsirm/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from __future__ import annotations

import math
import operator
from dataclasses import dataclass

import numpy as np

from .backend import normalize_backend, normalize_device


Expand Down Expand Up @@ -37,6 +38,31 @@
MAX_SIM_ITEMS_PER_DIM = 10_000
MAX_SIM_CELLS = 200_000_000

_NUMPY_INTEGER_SCALAR_TYPES = (
np.int8,
np.int16,
np.int32,
np.int64,
np.intp,
np.longlong,
np.uint8,
np.uint16,
np.uint32,
np.uint64,
np.uintp,
np.ulonglong,
)


def _trusted_integer(value: object, name: str) -> int:
"""Return one package-trusted integer without caller-controlled coercion."""
value_type = type(value)
if value_type is int:
return value
if any(value_type is trusted_type for trusted_type in _NUMPY_INTEGER_SCALAR_TYPES):
return int(value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int(value) is only used for local bounds. n_items still multiplies the stored fields, so np.uint8(16) * np.uint8(16) wraps to 0 after a successful validate. Write the trusted built-in ints back before simulate() reads n_items.

raise ValueError(f"{name} must be an integer")


@dataclass(frozen=True)
class MLS2PLMConfig:
Expand All @@ -58,6 +84,10 @@ class MLS2PLMConfig:
seed: int = 1
dtype: str = "float64"

def __post_init__(self) -> None:
"""Reject invalid simulation controls at construction."""
self.validate()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate() here still skips seed and never writes trusted integers back. A hostile or np.uint8 seed survives construction; int(config.seed) and config.seed + restart remain live callback/wrap sites. Successor #939 stores built-in ints after the allowlist.


@property
def n_items(self) -> int:
"""Total item count (``n_dims * items_per_dim``)."""
Expand All @@ -70,40 +100,35 @@ def validate(self) -> None:
a positive-definite trait equicorrelation from ``phi``, a finite
non-negative ``gamma``, and a supported ``dtype``.
"""
for name, value in (
("n_persons", self.n_persons),
("n_dims", self.n_dims),
("items_per_dim", self.items_per_dim),
("latent_dim", self.latent_dim),
):
if isinstance(value, bool):
raise ValueError(f"{name} must be an integer")
try:
operator.index(value)
except TypeError as exc:
raise ValueError(f"{name} must be an integer") from exc
if self.n_persons < 1:
n_persons = _trusted_integer(self.n_persons, "n_persons")
n_dims = _trusted_integer(self.n_dims, "n_dims")
items_per_dim = _trusted_integer(self.items_per_dim, "items_per_dim")
latent_dim = _trusted_integer(self.latent_dim, "latent_dim")

if n_persons < 1:
raise ValueError("n_persons must be >= 1")
if self.n_dims < 1:
if n_dims < 1:
raise ValueError("n_dims must be >= 1")
if self.items_per_dim < 1:
if items_per_dim < 1:
raise ValueError("items_per_dim must be >= 1")
if self.n_persons > MAX_SIM_PERSONS:
if n_persons > MAX_SIM_PERSONS:
raise ValueError(f"n_persons must be <= {MAX_SIM_PERSONS}")
if self.n_dims > MAX_SIM_DIMS:
if n_dims > MAX_SIM_DIMS:
raise ValueError(f"n_dims must be <= {MAX_SIM_DIMS}")
if self.items_per_dim > MAX_SIM_ITEMS_PER_DIM:
if items_per_dim > MAX_SIM_ITEMS_PER_DIM:
raise ValueError(f"items_per_dim must be <= {MAX_SIM_ITEMS_PER_DIM}")
if self.n_persons * self.n_items > MAX_SIM_CELLS:
n_items = n_dims * items_per_dim
simulation_cells = n_persons * n_items
if simulation_cells > MAX_SIM_CELLS:
raise ValueError(
f"n_persons x n_items ({self.n_persons * self.n_items}) exceeds the "
f"n_persons x n_items ({simulation_cells}) exceeds the "
f"{MAX_SIM_CELLS}-cell simulation budget"
)
if self.latent_dim < 1:
if latent_dim < 1:
raise ValueError("latent_dim must be >= 1")
if self.latent_dim > MAX_LATENT_DIM:
if latent_dim > MAX_LATENT_DIM:
raise ValueError(f"latent_dim must be <= {MAX_LATENT_DIM}")
if not (-1.0 / max(self.n_dims - 1, 1) < self.phi < 1.0):
if not (-1.0 / max(n_dims - 1, 1) < self.phi < 1.0):
raise ValueError("phi must produce a positive-definite equicorrelation matrix")
try:
gamma_is_finite = math.isfinite(self.gamma)
Expand Down Expand Up @@ -193,6 +218,10 @@ class FitConfig:
# cf. the ZI count-model guidance of Perumean-Chaney et al. (2013).
zero_inflation: bool = False

def __post_init__(self) -> None:
"""Reject invalid fit controls at construction."""
self.validate()

def normalized_model(self) -> str:
"""Return the model name upper-cased for case-insensitive matching."""
return self.model.upper()
Expand All @@ -209,31 +238,31 @@ def validate(self) -> None:
model = self.normalized_model()
if model not in VALID_MODELS:
raise ValueError(f"model must be one of {sorted(VALID_MODELS)}")
if not (1 <= self.latent_dim <= MAX_LATENT_DIM):
latent_dim = _trusted_integer(self.latent_dim, "latent_dim")
if not (1 <= latent_dim <= MAX_LATENT_DIM):
raise ValueError(f"latent_dim must be >= 1 and <= {MAX_LATENT_DIM}")
if self.optimizer not in VALID_OPTIMIZERS:
raise ValueError(f"optimizer must be one of {sorted(VALID_OPTIMIZERS)}")
if self.estimator not in VALID_ESTIMATORS:
raise ValueError(f"estimator must be one of {sorted(VALID_ESTIMATORS)}")
if model == "BIFAC2PLM" and self.estimator == "jmle":
raise ValueError("BIFAC2PLM requires estimator 'mmle'")
if isinstance(self.lbfgs_history, bool):
raise ValueError("lbfgs_history must be an integer")
try:
lbfgs_history = operator.index(self.lbfgs_history)
except TypeError as exc:
raise ValueError("lbfgs_history must be an integer") from exc

lbfgs_history = _trusted_integer(self.lbfgs_history, "lbfgs_history")
if not (1 <= lbfgs_history <= MAX_LBFGS_HISTORY):
raise ValueError(
f"lbfgs_history must be >= 1 and <= {MAX_LBFGS_HISTORY}"
)
if not (1 <= self.max_iter <= MAX_MAX_ITER):
max_iter = _trusted_integer(self.max_iter, "max_iter")
if not (1 <= max_iter <= MAX_MAX_ITER):
raise ValueError(f"max_iter must be >= 1 and <= {MAX_MAX_ITER}")
if not (1 <= self.n_restarts <= MAX_RESTARTS):
n_restarts = _trusted_integer(self.n_restarts, "n_restarts")
if not (1 <= n_restarts <= MAX_RESTARTS):
raise ValueError(f"n_restarts must be >= 1 and <= {MAX_RESTARTS}")
if self.max_iter * self.n_restarts > MAX_AGGREGATE_ITERS:
aggregate_iters = max_iter * n_restarts
if aggregate_iters > MAX_AGGREGATE_ITERS:
raise ValueError(
f"max_iter x n_restarts ({self.max_iter * self.n_restarts}) exceeds the "
f"max_iter x n_restarts ({aggregate_iters}) exceeds the "
f"aggregate optimizer-work budget {MAX_AGGREGATE_ITERS}"
)
# non-finite floats (NaN/Inf) slip past bare `<= 0` comparisons
Expand All @@ -249,24 +278,26 @@ def validate(self) -> None:
not math.isfinite(self.gradient_clip) or self.gradient_clip <= 0
):
raise ValueError("gradient_clip must be > 0 and finite, or None")

supported_q = {7, 11, 15, 21, 31, 41}
for name in ("q_theta", "q_xi", "q_u"):
if getattr(self, name) not in supported_q:
quadrature_nodes = _trusted_integer(getattr(self, name), name)
if quadrature_nodes not in supported_q:
raise ValueError(f"{name} must be one of {sorted(supported_q)}")
if not (1 <= self.m_steps <= MAX_M_STEPS):
m_steps = _trusted_integer(self.m_steps, "m_steps")
if not (1 <= m_steps <= MAX_M_STEPS):
raise ValueError(f"m_steps must be >= 1 and <= {MAX_M_STEPS}")
if self.xi_rule.lower() not in {"gh", "qmc", "halton", "mc", "montecarlo", "monte-carlo"}:
if self.xi_rule.lower() not in {
"gh",
"qmc",
"halton",
"mc",
"montecarlo",
"monte-carlo",
}:
raise ValueError("xi_rule must be one of ['gh', 'qmc', 'mc']")
for name in ("xi_points", "xi_seed"):
value = getattr(self, name)
if isinstance(value, bool):
raise ValueError(f"{name} must be an integer")
try:
operator.index(value)
except TypeError as exc:
raise ValueError(f"{name} must be an integer") from exc
xi_points = operator.index(self.xi_points)
xi_seed = operator.index(self.xi_seed)
xi_points = _trusted_integer(self.xi_points, "xi_points")
xi_seed = _trusted_integer(self.xi_seed, "xi_seed")
if not (1 <= xi_points <= MAX_XI_POINTS):
raise ValueError(f"xi_points must be >= 1 and <= {MAX_XI_POINTS}")
if not (0 <= xi_seed <= (1 << 64) - 1):
Expand Down
15 changes: 13 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ def test_mls2plmconfig_invalid_latent_dim():
MLS2PLMConfig(latent_dim=0).validate()

def test_mls2plmconfig_invalid_phi_lower_bound():
config = MLS2PLMConfig(n_dims=2, phi=-1.0)
with pytest.raises(ValueError, match="phi must produce a positive-definite equicorrelation matrix"):
config.validate()
MLS2PLMConfig(n_dims=2, phi=-1.0)

def test_mls2plmconfig_invalid_phi_upper_bound():
with pytest.raises(ValueError, match="phi must produce a positive-definite equicorrelation matrix"):
Expand Down Expand Up @@ -107,3 +106,15 @@ def test_fitconfig_rejects_noninteger_xi_controls(name, value):
def test_fitconfig_rejects_xi_seed_outside_u64(value):
with pytest.raises(ValueError, match="xi_seed must fit an unsigned 64-bit integer"):
FitConfig(xi_seed=value).validate()


def test_mls2plmconfig_rejects_invalid_values_at_construction():
"""Memory-safety bounds are enforced when the simulation config is built."""
with pytest.raises(ValueError, match="n_persons must be >= 1"):
MLS2PLMConfig(n_persons=-1)


def test_fitconfig_rejects_invalid_values_at_construction():
"""Memory-safety bounds are enforced when the fit config is built."""
with pytest.raises(ValueError, match="latent_dim must be >= 1 and <= "):
FitConfig(latent_dim=0)
Loading
Loading