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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 16 additions & 5 deletions crates/mlsirm-core/src/parallel.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
//!
Expand Down Expand Up @@ -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).
///
Expand Down Expand Up @@ -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::<f64>())
.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 {
Expand Down
7 changes: 7 additions & 0 deletions docs/changelog.d/627-parallel-analysis-control-bounds.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions docs/doctoring/parallel_analysis_control_bounds.md
Original file line number Diff line number Diff line change
@@ -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`
62 changes: 51 additions & 11 deletions python/fast_mlsirm/parallel_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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"]),
Expand Down
126 changes: 126 additions & 0 deletions tests/test_parallel_analysis_control_bounds.py
Original file line number Diff line number Diff line change
@@ -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)
Loading