Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/changelog.d/784-model-comparison-input-error-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Model-comparison hostile input redaction

## Fixed

- Model-comparison parameter counts and casewise iterables redact hostile conversion and iteration callback failures into stable package-owned `ValueError` messages while preserving `MemoryError`.
35 changes: 25 additions & 10 deletions python/fast_mlsirm/model_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,10 @@ def _parameter_count(value: Any, name: str) -> int:
raise ValueError(f"{name} must be a non-negative integer")
try:
normalized = operator.index(value)
except TypeError as exc:
raise ValueError(f"{name} must be a non-negative integer") from exc
except MemoryError:
raise
except Exception:
raise ValueError(f"{name} must be a non-negative integer") from None
if normalized < 0:
raise ValueError(f"{name} must be a non-negative integer")
return int(normalized)
Expand Down Expand Up @@ -171,17 +173,27 @@ def _omega_tolerance(value: Any) -> float:

def _casewise_values(value: Any, name: str) -> tuple[float, ...]:
"""Materialize and normalize a bounded iterable of finite numeric values."""
iterable_message = f"{name} must be an iterable of numeric casewise values"
if isinstance(value, (str, bytes)):
raise ValueError(f"{name} must be an iterable of numeric casewise values")
raise ValueError(iterable_message)
try:
iterator = iter(value)
except TypeError as exc:
raise ValueError(
f"{name} must be an iterable of numeric casewise values"
) from exc
except MemoryError:
raise
except Exception:
raise ValueError(iterable_message) from None

materialized: list[float] = []
for index, item in enumerate(iterator):
index = 0
while True:
try:
item = next(iterator)
except StopIteration:
break
except MemoryError:
raise
except Exception:
raise ValueError(iterable_message) from None
if index >= MAX_CASEWISE_VALUES:
raise ValueError(
f"{name} must contain at most {MAX_CASEWISE_VALUES} casewise values"
Expand All @@ -190,11 +202,14 @@ def _casewise_values(value: Any, name: str) -> tuple[float, ...]:
raise ValueError(f"{name}[{index}] must be a finite number")
try:
numeric = float(item)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(f"{name}[{index}] must be a finite number") from exc
except MemoryError:
raise
except Exception:
raise ValueError(f"{name}[{index}] must be a finite number") from None
if not math.isfinite(numeric):
raise ValueError(f"{name}[{index}] must be a finite number")
materialized.append(numeric)
index += 1
return tuple(materialized)


Expand Down
157 changes: 157 additions & 0 deletions tests/test_model_comparison_input_error_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Regression tests for hostile model-comparison input callbacks."""

from __future__ import annotations

import pytest

from fast_mlsirm.model_comparison import compare_nonnested_models


class _ExplodingIteratorFactory:
"""Raise a caller-controlled error before casewise iteration begins."""

def __iter__(self):
"""Fail while constructing the iterator with source-like text."""
raise RuntimeError("sensitive_iterator_factory_text_should_not_escape")


class _ExplodingIterable:
"""Yield one valid contribution and then raise a caller-controlled error."""

def __iter__(self):
"""Expose an iteration-time callback failure after a valid prefix."""
yield 0.25
raise RuntimeError("sensitive_iteration_text_should_not_escape")


class _ExplodingFloat:
"""Raise a caller-controlled error during numeric conversion."""

def __float__(self) -> float:
"""Fail during coercion with source-like text."""
raise RuntimeError("sensitive_float_text_should_not_escape")


class _ExplodingIndex:
"""Raise a caller-controlled error during parameter-count conversion."""

def __index__(self) -> int:
"""Fail during integer-index coercion with source-like text."""
raise RuntimeError("sensitive_index_text_should_not_escape")


class _MemoryIteratorFactory:
"""Raise resource exhaustion while constructing the casewise iterator."""

def __iter__(self):
"""Preserve process-level resource exhaustion from iterator creation."""
raise MemoryError("iterator allocation exhausted")


class _MemoryIterable:
"""Raise resource exhaustion after yielding one casewise contribution."""

def __iter__(self):
"""Preserve process-level resource exhaustion during iteration."""
yield 0.25
raise MemoryError("iteration allocation exhausted")


class _MemoryFloat:
"""Raise resource exhaustion during caller-controlled numeric coercion."""

def __float__(self) -> float:
"""Preserve process-level resource exhaustion from float conversion."""
raise MemoryError("float allocation exhausted")


class _MemoryIndex:
"""Raise resource exhaustion during caller-controlled index coercion."""

def __index__(self) -> int:
"""Preserve process-level resource exhaustion from index conversion."""
raise MemoryError("index allocation exhausted")


def _assert_redacted_value_error(callable_, sentinel: str, field_name: str) -> None:
"""Require a package-owned validation error without caller-controlled text."""
with pytest.raises(ValueError) as caught:
callable_()

message = str(caught.value)
assert field_name in message
assert sentinel not in message
assert caught.value.__cause__ is None


def test_casewise_iterator_factory_failure_is_redacted() -> None:
"""Iterator-construction failures must not escape the public selection API."""
_assert_redacted_value_error(
lambda: compare_nonnested_models(
_ExplodingIteratorFactory(),
(0.1, 0.2),
2,
2,
),
"sensitive_iterator_factory_text_should_not_escape",
"loglik_a",
)


def test_casewise_iteration_failure_after_valid_prefix_is_redacted() -> None:
"""Iteration-time failures must not leak caller exception text or type."""
_assert_redacted_value_error(
lambda: compare_nonnested_models(
_ExplodingIterable(),
(0.1, 0.2),
2,
2,
),
"sensitive_iteration_text_should_not_escape",
"loglik_a",
)


def test_casewise_numeric_conversion_failure_is_redacted() -> None:
"""Numeric conversion callbacks must fail through a package-owned boundary."""
_assert_redacted_value_error(
lambda: compare_nonnested_models(
(_ExplodingFloat(), 0.2),
(0.1, 0.2),
2,
2,
),
"sensitive_float_text_should_not_escape",
"loglik_a[0]",
)


def test_parameter_count_conversion_failure_is_redacted() -> None:
"""Parameter-count conversion callbacks must not disclose raw exceptions."""
_assert_redacted_value_error(
lambda: compare_nonnested_models(
(0.1, 0.2),
(0.1, 0.2),
_ExplodingIndex(), # type: ignore[arg-type]
2,
),
"sensitive_index_text_should_not_escape",
"k_a",
)


@pytest.mark.parametrize(
"callable_",
[
lambda: compare_nonnested_models(_MemoryIteratorFactory(), (0.1, 0.2), 2, 2),
lambda: compare_nonnested_models(_MemoryIterable(), (0.1, 0.2), 2, 2),
lambda: compare_nonnested_models((_MemoryFloat(), 0.2), (0.1, 0.2), 2, 2),
lambda: compare_nonnested_models(
(0.1, 0.2), (0.1, 0.2), _MemoryIndex(), 2 # type: ignore[arg-type]
),
],
)
def test_memory_error_remains_explicit_resource_exhaustion(callable_) -> None:
"""Resource exhaustion must not be downgraded into an input-validation error."""
with pytest.raises(MemoryError):
callable_()
Loading