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
9 changes: 9 additions & 0 deletions docs/changelog.d/683-ata-semantic-range-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# ATA semantic-range and exclusion preflight

## Fixed

- Reject negative or contradictory ATA content constraints, invalid exposure-map ranges, negative seeds, and non-integral or out-of-bank exclusions before item-information evaluation while preserving accepted Python/NumPy integer controls and assembly semantics.

## Security

- Keep ATA exclusion identities on an exact package-owned type/range boundary so Boolean, fractional, hostile integer-like, and out-of-bank values cannot be silently coerced or ignored before psychometric work.
54 changes: 33 additions & 21 deletions docs/doctoring/ata_constraint_map_validation.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,55 @@
# ATA constraint-map validation trust boundary
# ATA semantic-control validation trust boundary

## Status

Implemented for the public `assemble_to_target` semantic-control boundary. This note documents a bounded public-input validation correction; it does not change ATA psychometric arithmetic or authorize broader test-assembly optimality claims.
Implemented on the active `assemble_to_target` branch for exact semantic-control validation before item-information work. This note documents a bounded public-input correction; it does not change ATA item-information or greedy target-assembly arithmetic and does not claim exact test-assembly optimality.

## Problem

`assemble_to_target()` accepted caller-controlled content and exposure maps through unconstrained `str(key)` / `int(value)` conversion after item-information evaluation began. In Python, those conversions invoke `__str__`, `__int__`, and `__index__` special methods. Booleans are also integers under `isinstance`, so `True` could be silently admitted as a seed or exposure ceiling. Therefore conversion of an arbitrary caller object is executable behavior, not a passive type check (Python Software Foundation, 2026).
The first ATA trust-boundary correction rejected arbitrary `str(...)` / `int(...)` conversion for content/exposure maps, `seed`, and `exposure_max`. Fresh post-integration review found a second finite-domain gap: type-valid but invalid values could still reach psychometric work, and `exclude` still used NumPy integer coercion.

The public contract did not require arbitrary object-to-string or object-to-integer coercion for content keys, content counts, exposure identities, exposure counts, `seed`, or `exposure_max`. Permitting such coercion before semantic validation created two avoidable risks:
The residual cases were:

1. caller conversion callbacks could execute or raise outside the package-owned validation surface; and
2. invalid semantic controls could reach psychometric item-information work before rejection.
- negative `seed` reaching item-information work before NumPy RNG construction rejected it;
- negative content minimum/maximum counts;
- a per-label minimum exceeding its maximum;
- negative exposure counts or exposure item identities outside the calibrated bank;
- Boolean/fractional exclusions silently coercible to integer identities;
- arbitrary exclusion objects able to invoke `__int__`/`__index__`; and
- out-of-bank exclusions being silently ignored by the eligibility loop.

This is treated as a specified-type validation boundary consistent with CWE-1287: inputs expected to have a particular type should be validated as that type rather than accepted through unconstrained conversion (MITRE, 2026). CWE is used here as engineering taxonomy, not as a vulnerability-severity or certification claim. The correction continues the ATA content-label trust boundary (content elements already string-checked) for the remaining semantic control maps (van der Linden, 2005).
Python's data model makes numeric/string conversion executable behavior: special methods such as `__int__`, `__index__`, and `__str__` may run caller code rather than merely inspect a type (Python Software Foundation, 2026). CWE-1287 is used only as engineering taxonomy for specified-type validation, not as a vulnerability-severity or certification claim (MITRE, 2026).

## Decision

For this ATA boundary:
For public ATA semantic controls that are decidable from bank/control metadata before scoring:

- validate content constraint keys as Python `str` / NumPy `str_` before scoring;
- validate content counts, exposure keys/values, `seed`, and `exposure_max` as exact integers while rejecting `bool` and conversion hooks without invoking `__int__`/`__index__`;
- reject invalid controls with stable package-owned `ValueError` messages before `item_information_matrix` runs; and
- keep information computation, greedy target matching, content minima/maxima semantics, exclusion, exposure ineligibility, tie-breaking, and all Rust-owned numerical semantics unchanged once controls are admitted.
- content constraint keys admit only Python `str` / NumPy `str_`;
- content counts admit only exact non-Boolean Python/NumPy integers, must be non-negative, and a shared label's minimum cannot exceed its maximum;
- exposure item identities/counts admit only exact non-Boolean integers, counts must be non-negative, and item identities must exist in the calibrated bank;
- `seed` and `exposure_max` admit only exact non-Boolean integers and must be non-negative;
- `exclude` admits only a one-dimensional NumPy signed/unsigned integer array or an ordinary list/tuple of exact Python/NumPy integers; Boolean, fractional, object, arbitrary-iterable, and hostile integer-like values fail closed without invoking conversion hooks;
- exclusion indices must identify existing bank items rather than being silently ignored; and
- every invalid semantic control above fails with stable package-owned `ValueError` text before `item_information_matrix()` executes.

## Evidence contract
Once controls are admitted, item information, greedy capped-shortfall selection, content/exposure behavior, deterministic tie-breaking, and Rust-owned numerical semantics remain unchanged (van der Linden, 2005).

GREEN requires all of the following:
## Test-first evidence contract

- hostile content-map keys raise `content constraint keys must be strings` without `__str__`/`__repr__` execution;
- hostile content-map counts raise `content constraint counts must be integers` without `__int__`/`__index__` execution;
- hostile exposure-map identities raise `exposure_counts keys and values must be integers` without conversion hooks;
- boolean and fractional `seed` / `exposure_max` values raise `{field} must be an integer`;
- invalid controls are rejected before item-information computation; and
- the ordinary ATA suite plus package/Rust/PyO3/GPU/fuzz/security gates remain green.
The branch preserved an exact fail-first run in which package installation and Rust-primary resolution succeeded, the full Python suite reached the public ATA boundary, and the new semantic-range tests alone demonstrated that invalid controls still reached scoring or leaked caller conversion exceptions. GREEN requires, on one unchanged final head:

- negative seed/content/exposure values and impossible per-label min/max relations fail before information work;
- out-of-bank exposure/exclusion identities fail before information work;
- hostile exclusion objects do not execute `__int__` or `__index__`;
- stable error messages do not reflect rejected caller exception text;
- accepted Python/NumPy integer controls and list/tuple/NumPy integer exclusions still reach item-information work;
- equal feasible minimum/maximum content bounds remain accepted;
- ordinary ATA numerical/constraint behavior remains unchanged; and
- Python 3.12/3.14, Rust/PyO3, package/reinstall, existing GPU, fuzz, Security Scan, SAST, coverage/docstring and current-head review gates pass.

## Scope limitations

This correction does not re-home CAT/test-assembly numerical ownership into Rust, introduce a MIP solver, or claim optimality for the greedy assembly surrogate. Additional ATA control surfaces receive their own bounded regressions.
This correction does not move CAT/test-assembly numerical ownership into Rust, add an exact MIP/solver, alter calibrated item parameters, change target-information equations, or establish consequential-use validity. The separate Rust CAT/test-assembly ownership roadmap remains governed independently.

## References

Expand Down
78 changes: 63 additions & 15 deletions python/fast_mlsirm/ata.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ def _validated_content_labels(content: np.ndarray | None, n_items: int) -> np.nd
return labels.astype(str)



def _exact_public_integer(value: object, name: str) -> int:
"""Return one exact integer while rejecting bools and conversion hooks.

Expand All @@ -183,7 +182,7 @@ def _validated_content_constraints(
min_per_content: dict[str, int] | None,
max_per_content: dict[str, int] | None,
) -> tuple[dict[str, int], dict[str, int]]:
"""Validate content constraint maps without hostile key/count coercion."""
"""Validate content constraint maps and finite-domain count semantics."""

def _one(raw: dict[str, int] | None) -> dict[str, int]:
if raw is None:
Expand All @@ -196,14 +195,25 @@ def _one(raw: dict[str, int] | None) -> dict[str, int]:
raise ValueError("content constraint keys must be strings")
if isinstance(count, bool) or not isinstance(count, (int, np.integer)):
raise ValueError("content constraint counts must be integers")
out[str(key)] = int(count)
value = int(count)
if value < 0:
raise ValueError("content constraint counts must be non-negative")
out[str(key)] = value
return out

return _one(min_per_content), _one(max_per_content)
minimums = _one(min_per_content)
maximums = _one(max_per_content)
for label in minimums.keys() & maximums.keys():
if minimums[label] > maximums[label]:
raise ValueError("minimum content constraint cannot exceed maximum")
return minimums, maximums


def _validated_exposure_counts(exposure_counts: dict[int, int] | None) -> dict[int, int]:
"""Validate exposure maps without invoking integer conversion callbacks."""
def _validated_exposure_counts(
exposure_counts: dict[int, int] | None,
n_items: int,
) -> dict[int, int]:
"""Validate exposure-map types, ranges, and bank membership before scoring."""
if exposure_counts is None:
return {}
if not isinstance(exposure_counts, dict):
Expand All @@ -214,10 +224,47 @@ def _validated_exposure_counts(exposure_counts: dict[int, int] | None) -> dict[i
raise ValueError("exposure_counts keys and values must be integers")
if isinstance(count, bool) or not isinstance(count, (int, np.integer)):
raise ValueError("exposure_counts keys and values must be integers")
out[int(key)] = int(count)
item_index = int(key)
usage_count = int(count)
if not 0 <= item_index < n_items:
raise ValueError("exposure_counts keys must identify existing items")
if usage_count < 0:
raise ValueError("exposure_counts values must be non-negative")
out[item_index] = usage_count
return out


def _validated_exclude(exclude: object, n_items: int) -> set[int]:
"""Return exact item exclusions without invoking caller conversion hooks.

Only one-dimensional NumPy integer arrays and ordinary list/tuple containers
of exact Python/NumPy integers are admitted. Boolean, fractional, object and
arbitrary iterable inputs fail closed before psychometric scoring.
"""
if exclude is None:
return set()

values: list[object]
if isinstance(exclude, np.ndarray):
if exclude.ndim != 1 or exclude.dtype.kind not in {"i", "u"}:
raise ValueError("exclude must contain integer item indices")
values = exclude.tolist()
elif isinstance(exclude, (list, tuple)):
values = list(exclude)
else:
raise ValueError("exclude must contain integer item indices")

validated: set[int] = set()
for value in values:
if isinstance(value, bool) or not isinstance(value, (int, np.integer)):
raise ValueError("exclude must contain integer item indices")
item_index = int(value)
if not 0 <= item_index < n_items:
raise ValueError("exclude item indices must identify existing items")
validated.add(item_index)
return validated


def assemble_to_target(
bank: MLSIRMParams,
factor_id: np.ndarray,
Expand Down Expand Up @@ -249,15 +296,15 @@ def assemble_to_target(
Content labels and constraint-map keys/counts must already be the admitted
types (strings / exact integers); arbitrary objects and conversion hooks are
rejected before psychometric scoring rather than coerced through caller
callbacks. Deterministic given ``seed``. Raises ``ValueError`` if no form
satisfying the constraints can be assembled.
callbacks. Semantic count/range constraints and exclusion indices are also
validated before item-information evaluation. Deterministic given ``seed``.
Raises ``ValueError`` if no form satisfying the constraints can be assembled.
"""
n_items = int(np.asarray(bank.b).shape[0])
labels = _validated_content_labels(content, n_items)

# Validate semantic controls before any item-information evaluation so hostile
# constraint-map coercion cannot execute during scoring and invalid controls
# never reach the psychometric path.
# conversion hooks and invalid finite-domain controls never reach scoring.
if not isinstance(length, (int, np.integer)) or isinstance(length, bool):
raise ValueError("length must be an integer")
length = int(length)
Expand All @@ -268,13 +315,15 @@ def assemble_to_target(
if (min_counts or max_counts) and labels is None:
raise ValueError("content labels are required for content constraints")

excluded = set(np.asarray(exclude, dtype=np.int64).tolist()) if exclude is not None else set()
exposure_counts = _validated_exposure_counts(exposure_counts)
excluded = _validated_exclude(exclude, n_items)
exposure_counts = _validated_exposure_counts(exposure_counts, n_items)
if exposure_max is not None:
exposure_max = _exact_public_integer(exposure_max, "exposure_max")
if exposure_max < 0:
raise ValueError("exposure_max must be non-negative")
seed = _exact_public_integer(seed, "seed")
if seed < 0:
raise ValueError("seed must be non-negative")

matrix = item_information_matrix(bank, factor_id, target_thetas, model=model)
n_points, matrix_n_items = matrix.shape
Expand All @@ -298,8 +347,7 @@ def assemble_to_target(
for i in selected:
eligible[i] = False
for i in excluded:
if 0 <= i < n_items:
eligible[i] = False
eligible[i] = False
if exposure_max is not None:
for i in range(n_items):
if exposure_counts.get(i, 0) >= exposure_max:
Expand Down
Loading
Loading