From 4f0c2e95628c20ed7a29dafa9314dd88a8fc5605 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:02:58 +0900 Subject: [PATCH] rebuild: #802 unique product delta on current main --- CHANGELOG.md | 11 ++ ...4-model-comparison-input-error-boundary.md | 5 + python/fast_mlsirm/model_comparison.py | 35 ++-- ...t_model_comparison_input_error_boundary.py | 157 ++++++++++++++++++ 4 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 docs/changelog.d/784-model-comparison-input-error-boundary.md create mode 100644 tests/test_model_comparison_input_error_boundary.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bdeeeb7..bfc3b7413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -552,6 +552,17 @@ vocabulary and mark parallel-analysis control bounds and essay-report native dark-mode accents as ancestral after their integration. +#### Retire competing hourly review-repair caller + +- Remove the repository-local hourly review-repair GitHub Actions caller so only + the organization single-writer control plane schedules mutation loops, matching + ADR-0013 continuous-execution governance after failed startup evidence for the + local caller. + +#### Model-comparison hostile input redaction + +- Model-comparison parameter counts and casewise iterables redact hostile conversion and iteration callback failures into stable package-owned `ValueError` messages while preserving `MemoryError`. + #### Multilevel hostile numeric callback rejection - Multilevel membership weights and AR(1) coefficients now admit only exact diff --git a/docs/changelog.d/784-model-comparison-input-error-boundary.md b/docs/changelog.d/784-model-comparison-input-error-boundary.md new file mode 100644 index 000000000..0c5c02f28 --- /dev/null +++ b/docs/changelog.d/784-model-comparison-input-error-boundary.md @@ -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`. diff --git a/python/fast_mlsirm/model_comparison.py b/python/fast_mlsirm/model_comparison.py index 2ae9ae639..9c809a533 100644 --- a/python/fast_mlsirm/model_comparison.py +++ b/python/fast_mlsirm/model_comparison.py @@ -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) @@ -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" @@ -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) diff --git a/tests/test_model_comparison_input_error_boundary.py b/tests/test_model_comparison_input_error_boundary.py new file mode 100644 index 000000000..afbba7fbd --- /dev/null +++ b/tests/test_model_comparison_input_error_boundary.py @@ -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_()