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/1063-model-spec-record-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Model-spec record admission

## Fixed

- Model resolution now admits only exact package-owned exploratory and confirmatory model records before reading their fields, so caller-defined model-spec subclasses cannot execute attribute callbacks during validation. Exact built-in/concrete NumPy factor counts and exact package model records retain their existing behavior; multidimensional exploratory estimation remains separately governed by #633.
4 changes: 2 additions & 2 deletions python/fast_mlsirm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,14 @@ def _resolve_model(
raise TypeError("model must be a factor count or an IRT model specification")
if type(model) is int or _is_exact_numpy_integer_scalar(model):
model = ExploratoryModel(model)
if isinstance(model, ExploratoryModel):
if type(model) is ExploratoryModel:
if model.dimensions != 1:
raise NotImplementedError(
"multidimensional exploratory loading estimation is not implemented; "
"use models.confirmatory(...) for an identified loading structure"
)
return model, np.ones((n_items, 1), dtype=np.int64)
if isinstance(model, ConfirmatoryModel):
if type(model) is ConfirmatoryModel:
if model.loading_pattern.shape[0] != n_items:
raise ValueError(
"confirmatory model must have one loading-pattern row per item"
Expand Down
46 changes: 46 additions & 0 deletions tests/test_cov_a_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,52 @@ class HostileNumpyInt(np.int64, metaclass=HostileMeta):
assert calls == []


def test_resolve_model_rejects_exploratory_subclass_before_attribute_callback():
"""Reject exploratory subclasses before reading caller-controlled fields."""
calls: list[str] = []

class HostileExploratory(ExploratoryModel):
"""Expose a hostile dimensions attribute on a caller subclass."""

def __getattribute__(self, name: str):
"""Fail if model resolution reads caller-controlled dimensions."""
if name == "dimensions":
calls.append(name)
raise AssertionError("model attribute callback executed")
return super().__getattribute__(name)

spec = object.__new__(HostileExploratory)
object.__setattr__(spec, "dimensions", 1)

with pytest.raises(TypeError, match="factor count"):
_resolve_model(spec, 5)

assert calls == []


def test_resolve_model_rejects_confirmatory_subclass_before_attribute_callback():
"""Reject confirmatory subclasses before reading loading-pattern metadata."""
calls: list[str] = []

class HostileConfirmatory(ConfirmatoryModel):
"""Expose a hostile loading-pattern attribute on a caller subclass."""

def __getattribute__(self, name: str):
"""Fail if model resolution reads caller-controlled metadata."""
if name == "loading_pattern":
calls.append(name)
raise AssertionError("model attribute callback executed")
return super().__getattribute__(name)

spec = object.__new__(HostileConfirmatory)
object.__setattr__(spec, "loading_pattern", np.ones((5, 1), dtype=np.int64))

with pytest.raises(TypeError, match="factor count"):
_resolve_model(spec, 5)

assert calls == []


def test_resolve_model_multidim_exploratory_not_implemented():
with pytest.raises(NotImplementedError):
_resolve_model(2, 6)
Expand Down
Loading