Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
7 changes: 7 additions & 0 deletions docs/changelog.d/1366-crm-structural-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Bound CRM response traversal before NumPy materialization

## Fixed

- Continuous Response Model response admission now applies an independent structural-work ceiling while traversing exact built-in list/tuple evidence, so malformed zero-cell or deeply nested container fan-out cannot consume unbounded Python work before NumPy materialization.
- The structural ceiling is `2 ×` the existing 20,000,000 logical-cell envelope, which preserves every valid non-empty two-dimensional persons-by-items built-in matrix while bounding malformed container-only traversal.
- Existing callback-free cycle rejection, shared acyclic subtree handling, exact NumPy row compatibility, logical-cell accounting, NaN-only missingness, and Rust-owned Samejima CRM likelihood/EM/EAP arithmetic remain unchanged.
10 changes: 10 additions & 0 deletions python/fast_mlsirm/crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

_SUPPORTED_Q_THETA = (7, 11, 15, 21, 31, 41)
_MAX_CRM_RESPONSE_CELLS = 20_000_000
_MAX_CRM_RESPONSE_STRUCTURAL_NODES = 2 * _MAX_CRM_RESPONSE_CELLS
_NUMPY_INTEGER_TYPES = tuple(
np.dtype(name).type
for name in ("int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64")
Expand Down Expand Up @@ -79,6 +80,7 @@ def _trusted_response_array(responses: object) -> np.ndarray:
resource_error = (
f"responses must contain at most {_MAX_CRM_RESPONSE_CELLS:,} logical cells"
)
structural_error = "responses exceed structural traversal budget"
if type(responses) is np.ndarray:
if responses.size > _MAX_CRM_RESPONSE_CELLS:
raise ValueError(resource_error)
Expand All @@ -88,6 +90,11 @@ def _trusted_response_array(responses: object) -> np.ndarray:
if type(responses) is not list and type(responses) is not tuple:
raise ValueError(error)

# Every valid non-empty two-dimensional built-in matrix with N scalar cells
# visits at most N row entries plus N scalar entries. Keep a separate budget
# so malformed zero-cell/deep fan-out cannot evade the logical-cell ceiling.
structural_nodes = 0

# Each frame is [exact built-in container, next child index, logical-cell subtotal].
# Memoized subtotals let repeated/shared acyclic subtrees count by logical
# occurrence without re-traversing their descendants exponentially.
Expand Down Expand Up @@ -115,6 +122,9 @@ def _trusted_response_array(responses: object) -> np.ndarray:

frame[1] = child_index + 1
child = item[child_index]
structural_nodes += 1
if structural_nodes > _MAX_CRM_RESPONSE_STRUCTURAL_NODES:
raise ValueError(structural_error)
Comment thread
seonghobae marked this conversation as resolved.
child_type = type(child)

if _is_exact_type(child_type, _TRUSTED_RESPONSE_SCALAR_TYPES):
Expand Down
43 changes: 43 additions & 0 deletions tests/test_crm_response_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,49 @@ def unexpected_core() -> object:
crm.fit_crm(responses)


def test_zero_cell_fanout_is_bounded_before_sequence_materialization(monkeypatch):
"""Malformed empty-container fan-out is bounded independently of scalar cells."""

responses = [[] for _ in range(9)]

def unexpected_asarray(*args, **kwargs):
del args, kwargs
raise AssertionError("NumPy materialization ran before CRM structural admission")

def unexpected_core() -> object:
raise AssertionError("compiled core discovered before CRM structural admission")

monkeypatch.setattr(crm, "_MAX_CRM_RESPONSE_STRUCTURAL_NODES", 8)
monkeypatch.setattr(crm.np, "asarray", unexpected_asarray)
monkeypatch.setattr(fitstats, "_core_module", unexpected_core)

with pytest.raises(ValueError, match="structural traversal budget"):
crm.fit_crm(responses)


def test_valid_builtin_matrix_fits_tight_structural_budget(monkeypatch):
"""The 2N structural envelope preserves every valid non-empty 2-D matrix."""

captured: dict[str, tuple[object, ...]] = {}

class CapturingCore:
def fit_crm(self, *args):
captured["args"] = args
return _result()

monkeypatch.setattr(crm, "_MAX_CRM_RESPONSE_CELLS", 2)
monkeypatch.setattr(crm, "_MAX_CRM_RESPONSE_STRUCTURAL_NODES", 4)
monkeypatch.setattr(fitstats, "_core_module", lambda: CapturingCore())

fitted = crm.fit_crm([[0.25], [0.75]], max_iter=1)

args = captured["args"]
np.testing.assert_array_equal(args[0], np.array([0.25, 0.75], dtype=np.float64))
np.testing.assert_array_equal(args[1], np.array([True, True], dtype=bool))
assert args[2:4] == (2, 1)
assert fitted.n_parameters == 3


def test_builtin_response_matrix_preserves_trusted_numpy_scalar_marshalling(monkeypatch):
"""Ordinary built-in rows with concrete NumPy reals remain compatible."""

Expand Down
Loading