From 473aabfe7c2ddb7e9c8da68f53bf7d2431a05c7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:15:07 +0900 Subject: [PATCH 1/5] test(parallel): require bounded strict control validation --- .../test_parallel_analysis_control_bounds.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_parallel_analysis_control_bounds.py diff --git a/tests/test_parallel_analysis_control_bounds.py b/tests/test_parallel_analysis_control_bounds.py new file mode 100644 index 000000000..21c032c0f --- /dev/null +++ b/tests/test_parallel_analysis_control_bounds.py @@ -0,0 +1,126 @@ +"""Fail-first contracts for parallel-analysis control and workspace validation.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from fast_mlsirm.parallel_analysis import parallel_analysis + + +_DATA = np.array( + [ + [0.1, 1.0], + [0.4, 0.5], + [0.9, -0.2], + [1.2, -0.7], + ], + dtype=np.float64, +) + + +class _TrapCore: + """Fail if an invalid public control reaches compiled numerical dispatch.""" + + def parallel_analysis(self, *args, **kwargs): + raise AssertionError("invalid control reached Rust dispatch") + + +class _RecordingCore: + """Capture accepted controls without running the expensive numerical kernel.""" + + def __init__(self) -> None: + self.calls: list[tuple[object, ...]] = [] + + def parallel_analysis(self, *args): + self.calls.append(args) + return { + "retained": 1, + "eigenvalues": [1.5, 0.5], + "random_eigenvalues": [1.1, 0.9], + "bias": [0.1, -0.1], + "adjusted_eigenvalues": [1.4, 0.6], + } + + +def _install_core(monkeypatch: pytest.MonkeyPatch, core: object) -> None: + """Replace the package core loader used by the public wrapper.""" + import fast_mlsirm.fitstats as fitstats + + monkeypatch.setattr(fitstats, "_core_module", lambda: core) + + +@pytest.mark.parametrize( + ("name", "bad_value"), + [ + ("n_iterations", True), + ("n_iterations", 2.5), + ("n_iterations", "2"), + ("centile", True), + ("centile", 50.5), + ("centile", "50"), + ("seed", True), + ("seed", 1.5), + ("seed", "1"), + ], +) +def test_noninteger_controls_fail_before_rust_dispatch( + monkeypatch: pytest.MonkeyPatch, + name: str, + bad_value: object, +) -> None: + """Booleans, floats, and strings cannot be silently coerced to controls.""" + _install_core(monkeypatch, _TrapCore()) + kwargs: dict[str, object] = {"n_iterations": 2, "centile": 0, "seed": 1} + kwargs[name] = bad_value + + with pytest.raises(ValueError, match=rf"^{name} "): + parallel_analysis(_DATA, **kwargs) + + +def test_hostile_integer_conversion_is_not_executed(monkeypatch: pytest.MonkeyPatch) -> None: + """Control validation must use admitted types rather than caller conversion hooks.""" + _install_core(monkeypatch, _TrapCore()) + + class HostileInt: + def __int__(self) -> int: + raise RuntimeError("caller-controlled conversion executed") + + with pytest.raises(ValueError, match=r"^n_iterations "): + parallel_analysis(_DATA, n_iterations=HostileInt()) # type: ignore[arg-type] + + +def test_oversized_random_benchmark_workspace_fails_before_rust_dispatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller-controlled iteration counts cannot request unbounded simulation storage.""" + _install_core(monkeypatch, _TrapCore()) + + with pytest.raises(ValueError, match="workspace"): + parallel_analysis(_DATA, n_iterations=2**62) + + +def test_seed_must_fit_rust_u64_before_dispatch(monkeypatch: pytest.MonkeyPatch) -> None: + """Python validates the PyO3 integer transport range with a stable package error.""" + _install_core(monkeypatch, _TrapCore()) + + with pytest.raises(ValueError, match=r"^seed "): + parallel_analysis(_DATA, n_iterations=2, seed=2**64) + + +def test_numpy_integer_controls_remain_accepted(monkeypatch: pytest.MonkeyPatch) -> None: + """Exact NumPy integer scalars retain the documented public contract.""" + core = _RecordingCore() + _install_core(monkeypatch, core) + + result = parallel_analysis( + _DATA, + n_iterations=np.int64(2), + centile=np.int64(50), + seed=np.int64(7), + ) + + assert result.retained == 1 + assert len(core.calls) == 1 + call = core.calls[0] + assert call[-3:] == (2, 50, 7) From 1ac1293f3ef028dc87cec1c7a2079bd16d4511ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:32:17 +0900 Subject: [PATCH 2/5] fix(parallel): validate public integer controls --- python/fast_mlsirm/parallel_analysis.py | 62 ++++++++++++++++++++----- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/python/fast_mlsirm/parallel_analysis.py b/python/fast_mlsirm/parallel_analysis.py index 463f27eaf..aea9e36e9 100644 --- a/python/fast_mlsirm/parallel_analysis.py +++ b/python/fast_mlsirm/parallel_analysis.py @@ -9,6 +9,10 @@ import numpy as np +_MAX_PARALLEL_RANDOM_WORKSPACE_BYTES = 128 * 1024 * 1024 +_U64_MAX = (1 << 64) - 1 + + @dataclass class ParallelAnalysisResult: """Parallel-analysis outputs, all vectors in descending observed- @@ -37,6 +41,33 @@ class ParallelAnalysisResult: """ +def _integer_control( + name: str, + value: object, + *, + minimum: int, + maximum: int | None = None, +) -> int: + """Validate an integer control without invoking caller conversion hooks.""" + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)): + raise ValueError(f"{name} must be an integer") + parsed = int(value) + if parsed < minimum: + raise ValueError(f"{name} must be >= {minimum}") + if maximum is not None and parsed > maximum: + raise ValueError(f"{name} must be <= {maximum}") + return parsed + + +def _validate_random_workspace(n_iterations: int, n_items: int) -> None: + """Reject random-eigenvalue workspaces above the package safety ceiling.""" + workspace_bytes = n_iterations * n_items * np.dtype(np.float64).itemsize + if workspace_bytes > _MAX_PARALLEL_RANDOM_WORKSPACE_BYTES: + raise ValueError( + "parallel analysis random benchmark workspace exceeds 128 MiB" + ) + + def parallel_analysis( data: np.ndarray, n_iterations: int | None = None, @@ -59,9 +90,12 @@ def parallel_analysis( (R type-7 quantile) instead — Glorfeld's conservative variant. ``n_iterations`` defaults to ``30 * n_items`` (paran's default). The random stream is this crate's deterministic LCG — results are - paran-inspired but not bit-identical to any R run. In LLM-as-a-Judge - quality management this estimates how many latent dimensions the judge - rubric actually measures. + paran-inspired but not bit-identical to any R run. Integer controls + accept Python and NumPy integer scalars but reject booleans and implicit + conversion hooks. The random-eigenvalue benchmark workspace is bounded + to 128 MiB before compiled dispatch. In LLM-as-a-Judge quality management + this estimates how many latent dimensions the judge rubric actually + measures. """ from .fitstats import _core_module @@ -73,15 +107,21 @@ def parallel_analysis( if x.ndim != 2: raise ValueError("data must be a 2-D persons x items array") n_persons, n_items = x.shape - iters = 30 * n_items if n_iterations is None else int(n_iterations) - if iters < 1: - raise ValueError("n_iterations must be >= 1") - if not 0 <= int(centile) <= 99: - raise ValueError("centile must be 0 (mean) or in 1..=99") - if int(seed) < 0: - raise ValueError("seed must be non-negative") + iters = ( + 30 * n_items + if n_iterations is None + else _integer_control("n_iterations", n_iterations, minimum=1) + ) + centile_value = _integer_control("centile", centile, minimum=0, maximum=99) + seed_value = _integer_control("seed", seed, minimum=0, maximum=_U64_MAX) + _validate_random_workspace(iters, n_items) res = core.parallel_analysis( - x.reshape(-1), int(n_persons), int(n_items), iters, int(centile), int(seed) + x.reshape(-1), + int(n_persons), + int(n_items), + iters, + centile_value, + seed_value, ) return ParallelAnalysisResult( retained=int(res["retained"]), From b97daa999f2eff2f12adef3c8780b3b131832304 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:33:29 +0900 Subject: [PATCH 3/5] fix(parallel): bound Rust benchmark workspace --- crates/mlsirm-core/src/parallel.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/mlsirm-core/src/parallel.rs b/crates/mlsirm-core/src/parallel.rs index bc12ee701..66d2c9fd7 100644 --- a/crates/mlsirm-core/src/parallel.rs +++ b/crates/mlsirm-core/src/parallel.rs @@ -1,4 +1,4 @@ -//! Horn's parallel analysis for principal-component retention. +//! Horn's parallel analysis for principal-component retention. //! //! Compares the eigenvalues of the Pearson correlation matrix of an observed //! `n x p` data matrix against the mean (or a Glorfeld upper-centile) of @@ -36,7 +36,8 @@ //! 3. **Guards narrowed.** The oracle feeds `cor()`/`eigen()` whatever it is //! given; this implementation rejects `n_persons < 3`, `n_items < 2`, //! non-finite cells, zero-variance columns, `n_iterations == 0`, and -//! `centile > 99` with explicit errors. +//! `centile > 99` with explicit errors. The random-eigenvalue simulation +//! workspace is capped at 128 MiB before allocation. //! 4. `iterations = 0` does NOT default to `30 * p` here; the core is //! explicit and callers supply the default. //! @@ -79,6 +80,7 @@ pub struct ParallelAnalysisResult { const JACOBI_MAX_SWEEPS: usize = 100; const JACOBI_TOL: f64 = 1e-12; +const MAX_PARALLEL_RANDOM_WORKSPACE_BYTES: usize = 128 * 1024 * 1024; /// Horn's parallel analysis (PCA path of `paran`, see module docs). /// @@ -121,15 +123,24 @@ pub fn parallel_analysis( return Err("data must be finite (no NaN/inf; complete data required)".into()); } + let sim_len = n_iterations + .checked_mul(n_items) + .ok_or("parallel analysis random benchmark workspace size overflows usize")?; + let sim_bytes = sim_len + .checked_mul(std::mem::size_of::()) + .ok_or("parallel analysis random benchmark workspace size overflows usize")?; + if sim_bytes > MAX_PARALLEL_RANDOM_WORKSPACE_BYTES { + return Err(format!( + "parallel analysis random benchmark workspace exceeds {MAX_PARALLEL_RANDOM_WORKSPACE_BYTES} bytes" + )); + } + let corr = correlation_matrix(data, n_persons, n_items)?; let eigenvalues = symmetric_eigenvalues_desc(&corr, n_items)?; // Random benchmark: n_iterations standard-normal data sets from a single // deterministic LCG stream (crate idiom; see module docs, divergence 2). let mut state = seed.max(1); - let sim_len = n_iterations - .checked_mul(n_items) - .ok_or("n_iterations * n_items overflows usize")?; let mut sim = vec![0.0_f64; sim_len]; let mut rand_data = vec![0.0_f64; cells]; for k in 0..n_iterations { From 14039097ff0e748a63bc85b8c4d2c10bf1f6a088 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:34:45 +0900 Subject: [PATCH 4/5] docs(changelog): record parallel-analysis input bounds --- docs/changelog.d/627-parallel-analysis-control-bounds.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/changelog.d/627-parallel-analysis-control-bounds.md diff --git a/docs/changelog.d/627-parallel-analysis-control-bounds.md b/docs/changelog.d/627-parallel-analysis-control-bounds.md new file mode 100644 index 000000000..d2a29e4f9 --- /dev/null +++ b/docs/changelog.d/627-parallel-analysis-control-bounds.md @@ -0,0 +1,7 @@ +# Parallel-analysis input and workspace bounds + +## Fixed + +- `parallel_analysis()` now rejects booleans, floats, strings, and caller-defined integer-conversion hooks for integer controls instead of silently coercing them before Rust dispatch. +- The public wrapper validates the Rust `u64` seed range and rejects oversized random-eigenvalue benchmark workspaces before PyO3 dispatch. +- `mlsirm-core` independently caps the random-eigenvalue simulation workspace at 128 MiB before allocation while preserving the existing Horn/paran numerical algorithm and deterministic RNG contract. From ffead4baeade55530a9a80b4b7584776309615a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:37:40 +0900 Subject: [PATCH 5/5] docs(doctoring): APA citations for parallel-analysis control bounds --- CHANGELOG.md | 1 + .../parallel_analysis_control_bounds.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 docs/doctoring/parallel_analysis_control_bounds.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e0b63742..3d6ee1a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Validate parallel-analysis integer controls and bound random-eigenvalue workspace before Rust dispatch. - Cap LLM-judge response JSON nesting at 32 levels before parse to prevent recursive-object resource exhaustion. - Public fixed-form `assemble_test_form` delegates greedy maximum-information selection and content-feasibility look-ahead to the Rust core (`assemble_test_form_greedy`). - Public fixed-anchor `link_fixed_item_parameters` delegates affine scale/shift estimation and parameter transformation to the Rust core. diff --git a/docs/doctoring/parallel_analysis_control_bounds.md b/docs/doctoring/parallel_analysis_control_bounds.md new file mode 100644 index 000000000..adecc660c --- /dev/null +++ b/docs/doctoring/parallel_analysis_control_bounds.md @@ -0,0 +1,17 @@ +# Parallel analysis public control and workspace bounds + +## Standards and literature + +Horn, J. L. (1965). A rationale and test for the number of factors in factor analysis. *Psychometrika, 30*(2), 179–185. https://doi.org/10.1007/BF02289447 + +Glorfeld, L. W. (1995). An improvement on Horn's parallel analysis methodology for selecting the correct number of factors to retain. *Educational and Psychological Measurement, 55*(3), 377–393. https://doi.org/10.1177/0013164495055003002 + +Open Web Application Security Project. (2021). *OWASP API security top 10 2023: API4 — unrestricted resource consumption*. OWASP Foundation. https://owasp.org/API-Security/ + +## Product application + +Public `parallel_analysis` controls (`n_iterations`, `centile`, `seed`) are validated as exact integers of admitted types before PyO3 dispatch. Hostile `__int__` converters, booleans, floats, and strings fail closed. Iteration counts that would request an unbounded random-eigenvalue workspace are rejected in both the Python boundary and the Rust kernel allocation path so callers cannot exhaust memory with a single control value. + +## Verification + +- `tests/test_parallel_analysis_control_bounds.py`