diff --git a/docs/changelog.d/872-config-integer-callback-safety.md b/docs/changelog.d/872-config-integer-callback-safety.md new file mode 100644 index 000000000..103ac943e --- /dev/null +++ b/docs/changelog.d/872-config-integer-callback-safety.md @@ -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. diff --git a/docs/doctoring/config_integer_callback_safety.md b/docs/doctoring/config_integer_callback_safety.md new file mode 100644 index 000000000..8db4d7da8 --- /dev/null +++ b/docs/doctoring/config_integer_callback_safety.md @@ -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. diff --git a/fuzz/README.md b/fuzz/README.md index b8d52434f..ed5102ec1 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -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 diff --git a/fuzz/atheris/fuzz_config.py b/fuzz/atheris/fuzz_config.py index 9a60cc834..715c00ed5 100644 --- a/fuzz/atheris/fuzz_config.py +++ b/fuzz/atheris/fuzz_config.py @@ -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 @@ -44,17 +45,17 @@ 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 @@ -62,20 +63,20 @@ def _test_one_input(data: bytes) -> None: # 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 diff --git a/python/fast_mlsirm/config.py b/python/fast_mlsirm/config.py index 1413d73a7..c81deabbb 100644 --- a/python/fast_mlsirm/config.py +++ b/python/fast_mlsirm/config.py @@ -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 @@ -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) + raise ValueError(f"{name} must be an integer") + @dataclass(frozen=True) class MLS2PLMConfig: @@ -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() + @property def n_items(self) -> int: """Total item count (``n_dims * items_per_dim``).""" @@ -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) @@ -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() @@ -209,7 +238,8 @@ 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)}") @@ -217,23 +247,22 @@ def validate(self) -> None: 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 @@ -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): diff --git a/tests/test_config.py b/tests/test_config.py index d5f06d727..257f0a67d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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"): @@ -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) diff --git a/tests/test_config_integer_callback_safety.py b/tests/test_config_integer_callback_safety.py new file mode 100644 index 000000000..b10df8a6a --- /dev/null +++ b/tests/test_config_integer_callback_safety.py @@ -0,0 +1,122 @@ +"""Callback-safety regressions for public integer configuration controls.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from fast_mlsirm.config import FitConfig, MLS2PLMConfig + + +class _HostileIndex: + """Integer-like caller object whose coercion callback must never execute.""" + + calls = 0 + + def __index__(self) -> int: + """Record forbidden coercion and return an otherwise valid value.""" + type(self).calls += 1 + return 2 + + +class _HostileInt(int): + """Caller-defined integer subclass that is outside the trusted boundary.""" + + +def _assert_rejected_without_index_callback(callable_) -> None: + """Require validation to fail before invoking caller-controlled coercion.""" + _HostileIndex.calls = 0 + with pytest.raises(ValueError): + callable_(_HostileIndex()) + assert _HostileIndex.calls == 0 + + +@pytest.mark.parametrize( + "field", + ["n_persons", "n_dims", "items_per_dim", "latent_dim"], +) +def test_simulation_integer_controls_reject_index_callbacks(field: str) -> None: + """Simulation-size validation rejects arbitrary index providers inertly.""" + _assert_rejected_without_index_callback( + lambda value: MLS2PLMConfig(**{field: value}) + ) + + +@pytest.mark.parametrize( + "field", + [ + "latent_dim", + "lbfgs_history", + "max_iter", + "n_restarts", + "m_steps", + "xi_points", + "xi_seed", + ], +) +def test_fit_integer_controls_reject_index_callbacks(field: str) -> None: + """Fit integer validation never dispatches arbitrary ``__index__`` hooks.""" + _assert_rejected_without_index_callback( + lambda value: FitConfig(**{field: value}) + ) + + +@pytest.mark.parametrize( + ("config_type", "field", "valid_value"), + [ + (MLS2PLMConfig, "n_persons", 2), + (MLS2PLMConfig, "n_dims", 2), + (MLS2PLMConfig, "items_per_dim", 2), + (MLS2PLMConfig, "latent_dim", 2), + (FitConfig, "latent_dim", 2), + (FitConfig, "lbfgs_history", 2), + (FitConfig, "max_iter", 2), + (FitConfig, "n_restarts", 2), + (FitConfig, "q_theta", 21), + (FitConfig, "q_xi", 11), + (FitConfig, "q_u", 15), + (FitConfig, "m_steps", 2), + (FitConfig, "xi_points", 2), + (FitConfig, "xi_seed", 2), + ], +) +def test_integer_controls_reject_caller_int_subclasses( + config_type, + field: str, + valid_value: int, +) -> None: + """Caller-defined ``int`` subclasses are not package-trusted controls.""" + with pytest.raises(ValueError, match=rf"{field} must be an integer"): + config_type(**{field: _HostileInt(valid_value)}) + + +@pytest.mark.parametrize("value", [1, np.int32(2), np.int64(3), np.uint64(4)]) +def test_simulation_preserves_trusted_integer_scalars(value: object) -> None: + """Built-in and genuine NumPy integers remain valid simulation controls.""" + MLS2PLMConfig(n_persons=value).validate() + + +@pytest.mark.parametrize("value", [1, np.int32(2), np.int64(3), np.uint64(4)]) +def test_fit_preserves_trusted_integer_scalars(value: object) -> None: + """Built-in and genuine NumPy integers remain valid fit controls.""" + FitConfig(lbfgs_history=value, xi_points=value, xi_seed=value).validate() + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("latent_dim", np.int32(2)), + ("max_iter", np.int64(10)), + ("n_restarts", np.uint64(2)), + ("q_theta", np.int64(21)), + ("q_xi", np.uint64(11)), + ("q_u", np.int32(15)), + ("m_steps", np.int64(4)), + ], +) +def test_fit_preserves_trusted_integer_scalars_across_bounded_controls( + field: str, + value: object, +) -> None: + """Trusted NumPy integer scalars remain valid across bounded fit controls.""" + FitConfig(**{field: value}).validate() diff --git a/tests/test_fuzz_properties.py b/tests/test_fuzz_properties.py index 8e158a592..1c22157a1 100644 --- a/tests/test_fuzz_properties.py +++ b/tests/test_fuzz_properties.py @@ -104,16 +104,16 @@ def test_render_report_never_crashes_and_escapes(tmp_path_factory, payload): def test_mls2plm_config_validate_total( n_persons, n_dims, items_per_dim, latent_dim, phi, gamma, dtype ): - cfg = MLS2PLMConfig( - n_persons=n_persons, - n_dims=n_dims, - items_per_dim=items_per_dim, - latent_dim=latent_dim, - phi=phi, - gamma=gamma, - dtype=dtype, - ) try: + cfg = MLS2PLMConfig( + n_persons=n_persons, + n_dims=n_dims, + items_per_dim=items_per_dim, + latent_dim=latent_dim, + phi=phi, + gamma=gamma, + dtype=dtype, + ) cfg.validate() except ValueError: return @@ -143,18 +143,18 @@ def test_fit_config_validate_total( init_gamma, backend, ): - cfg = FitConfig( - model=model, - latent_dim=latent_dim, - optimizer=optimizer, - max_iter=max_iter, - n_restarts=n_restarts, - learning_rate=learning_rate, - eps_distance=eps_distance, - init_gamma=init_gamma, - backend=backend, - ) try: + cfg = FitConfig( + model=model, + latent_dim=latent_dim, + optimizer=optimizer, + max_iter=max_iter, + n_restarts=n_restarts, + learning_rate=learning_rate, + eps_distance=eps_distance, + init_gamma=init_gamma, + backend=backend, + ) cfg.validate() except ValueError: return diff --git a/tests/test_model_estimator_compatibility.py b/tests/test_model_estimator_compatibility.py index 4c4e1255e..14ca88f51 100644 --- a/tests/test_model_estimator_compatibility.py +++ b/tests/test_model_estimator_compatibility.py @@ -13,19 +13,16 @@ def test_fit_config_model_estimator_compatibility_matrix( model: str, estimator: str ) -> None: """Every advertised model-estimator pair must match executable fit support.""" - config = FitConfig(model=model, estimator=estimator) - if model == "BIFAC2PLM" and estimator == "jmle": with pytest.raises(ValueError, match="BIFAC2PLM.*mmle"): - config.validate() + FitConfig(model=model, estimator=estimator) return + config = FitConfig(model=model, estimator=estimator) config.validate() def test_bifactor_jmle_fails_during_configuration_validation() -> None: """Bifactor JMLE must fail before response preparation or fitting work.""" - config = FitConfig(model="BIFAC2PLM", estimator="jmle") - with pytest.raises(ValueError, match="BIFAC2PLM.*mmle"): - config.validate() + FitConfig(model="BIFAC2PLM", estimator="jmle")