Skip to content
Closed
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,11 @@
tau update, and the covariate update while preserving the Rust production
backend and public model contracts.

#### S-X2 and person-fit Rust ownership fail-closed

- Public `s_x2()` and `person_fit()` require the compiled Rust core entrypoints and no longer fall back to Python/NumPy numerical implementations when the core or symbols are missing.
- `s_x2()` always dispatches trait `prior_mean` through the native S-X² entrypoint instead of selecting the Python reference path whenever a prior is supplied.

#### Parallel-analysis input and workspace bounds

- `parallel_analysis()` now rejects booleans, floats, strings, and caller-defined integer-conversion hooks for integer controls instead of silently coercing them before Rust dispatch.
Expand Down Expand Up @@ -535,6 +540,23 @@
- Cap raw LLM-judge response JSON nesting at 32 levels before `json.loads`, failing closed with `JudgeFormatError` so hostile recursive objects cannot expand into parser resource exhaustion.
- Keep valid shallow judge payloads accepted with the existing criterion/score contracts.

#### Fit-statistics infit/outfit and M2 fail closed

- Public `infit_outfit()` and ordinary `m2()` fail closed when the compiled Rust
core or required entrypoints are missing, completing the residual ownership
gaps from issue #627 after S-X² and person-fit hardening.

#### Documentation coverage vocabulary and shipped-capability matrix

- Align architecture documentation contracts with the protected-main maturity
vocabulary and mark parallel-analysis control bounds and essay-report native
dark-mode accents as ancestral after their integration.

#### Rubric iterable error redaction

- Rubric model iterable validation fails closed with package-owned errors and
redacts hostile iterator exception text from public boundaries.

#### Diagnostics-report focus and contrast preservation

- Revealed the visually hidden diagnostics-report skip link for every actual `:focus` state while retaining the explicit `:focus-visible` treatment and strong outline.
Expand Down
7 changes: 7 additions & 0 deletions docs/changelog.d/776-fitstats-infit-m2-failclosed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Fit-statistics infit/outfit and M2 fail closed

## Fixed

- Public `infit_outfit()` and ordinary `m2()` fail closed when the compiled Rust
core or required entrypoints are missing, completing the residual ownership
gaps from issue #627 after S-X² and person-fit hardening.
7 changes: 7 additions & 0 deletions docs/changelog.d/777-docs-coverage-vocabulary-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Documentation coverage vocabulary and shipped-capability matrix

## Fixed

- Align architecture documentation contracts with the protected-main maturity
vocabulary and mark parallel-analysis control bounds and essay-report native
dark-mode accents as ancestral after their integration.
6 changes: 6 additions & 0 deletions docs/changelog.d/783-rubric-iterable-error-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Rubric iterable error redaction

## Fixed

- Rubric model iterable validation fails closed with package-owned errors and
redacts hostile iterator exception text from public boundaries.
9 changes: 4 additions & 5 deletions docs/documentation_coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ The table below records product truth, not documentation-file presence. “Imple
| Fixed-anchor parameter linking arithmetic | IMPLEMENTED_ON_PROTECTED_MAIN | protected main owns scale/shift estimation and theta/alpha/b transformation in Rust/PyO3 |
| Observed-information Hessian and second-order diagnostics | IMPLEMENTED_ON_PROTECTED_MAIN | protected main owns finite-difference coefficients/symmetric Hessian assembly and eigenvalue/positive-definiteness diagnostics in Rust/PyO3; Python only evaluates objective samples and transports results |
| JMLE Adam/L-BFGS optimizer arithmetic | IMPLEMENTED_ON_PROTECTED_MAIN | PR #760 is ancestral to current protected main; `backend="rust"` delegates Adam/L-BFGS/combined optimizer control to compiled Rust while recovery evidence remains governed separately by issue #626 |
| Parallel-analysis public control/resource hardening | IMPLEMENTED_ON_ACTIVE_PR | current fail-first/implementation PR owns strict integer/control and bounded-workspace hardening; it remains non-shipped until exact-head integration |
| Parallel-analysis public control/resource hardening | IMPLEMENTED_ON_PROTECTED_MAIN | strict integer/control validation and bounded random-benchmark workspace ceilings are ancestral to protected main |
| Hourly review-repair caller | IMPLEMENTED_ON_PROTECTED_MAIN / PARTIAL | PR #763 integrated the product-side bounded caller; operational scheduler/control-plane acceptance remains external evidence rather than a library capability |
| LLM-judge raw JSON depth hardening | IMPLEMENTED_ON_PROTECTED_MAIN | PR #764 is ancestral to current protected main and bounds recursive JSON nesting before parser materialization |
| Essay-report native dark-mode status accents | IMPLEMENTED_ON_ACTIVE_PR | current accessibility PR owns the CSS-variable/media-query change; do not treat it as protected-main until integration |
| Essay-report native dark-mode status accents | IMPLEMENTED_ON_PROTECTED_MAIN | CSS-variable and prefers-color-scheme dark-mode status accents are ancestral to protected main |
| Canonical PyO3/public-export governance | ACCEPTED_ARCHITECTURE / PARTIAL | ADR-0011 governs convergence; feature-by-feature hardening continues |
| Purpose-limited sensitive-data handling | IMPLEMENTED_ON_PROTECTED_MAIN / DOWNSTREAM | reusable contracts prefer purpose limitation/minimization/separated identities; hosted authorization/retention execution remains downstream |
| LLM orchestration/model credentials | IMPLEMENTED_ON_PROTECTED_MAIN | provider execution and independent reviewer identity/credential boundaries are governed; provider calls remain outside psychometric numerical core |
Expand All @@ -95,9 +95,8 @@ The table below records product truth, not documentation-file presence. “Imple

At this review, material open work includes:

- strict/bounded public controls and Rust allocation preflight for parallel analysis;
- the documentation-fitness refresh itself, which may describe current protected truth but is not authoritative until merged; and
- native dark-mode report status accents.
- Rust allocation preflight for parallel analysis, if it remains separate;
- the documentation-fitness refresh itself, which may describe current protected truth but is not authoritative until merged.

These remain active-PR evidence, not protected-main capability. Their source heads, checks, reviews, writer leases, and mergeability are operational evidence and must be re-fetched rather than copied into timeless architecture prose.

Expand Down
166 changes: 66 additions & 100 deletions python/fast_mlsirm/fitstats.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,61 +635,39 @@ def infit_outfit(
mask: np.ndarray | None = None,
eps_distance: float = 1e-8,
) -> dict[str, np.ndarray]:
"""Per-item infit/outfit mean squares at the EAP estimates."""
"""Per-item infit/outfit mean squares at the EAP estimates.

Production numerical ownership is the compiled Rust core
(``infit_outfit_stat``). Missing or incomplete cores fail closed.
"""
model = model.upper()
free_alpha = model not in {"MLSRM", "ULSRM"}
uses_space = model != "MIRT"
y, observed, d_of_i = _prepare_dichotomous_diagnostic_inputs(
responses, factor_id, mask
)
core = _core_module()
if core is not None and hasattr(core, "infit_outfit_stat"):
n_persons = y.shape[0]
n_dims = int(d_of_i.max()) + 1
bank = _bank_args(params, d_of_i, model, n_dims, eps_distance)
res = core.infit_outfit_stat(
y.ravel(),
observed.ravel(),
int(n_persons),
bank["alpha"],
bank["b"],
bank["zeta"],
bank["tau"],
bank["factor_id"],
bank["model"],
bank["n_dims"],
bank["latent_dim"],
bank["eps_distance"],
np.asarray(params.theta, dtype=np.float64).ravel(),
np.asarray(params.xi, dtype=np.float64).ravel(),
)
return {"infit": np.asarray(res["infit"]), "outfit": np.asarray(res["outfit"])}
a = np.exp(params.alpha) if free_alpha else np.ones(len(params.b))
eta = a[None, :] * np.asarray(params.theta)[:, d_of_i] + params.b[None, :]
if uses_space:
# Optimized distance computation: replace O(N*J*D) 3D broadcast with O(N*J) 2D dot product
xi = np.asarray(params.xi)
zeta = np.asarray(params.zeta)
x_sq = np.einsum("ij,ij->i", xi, xi)
z_sq = np.einsum("ij,ij->i", zeta, zeta)
dist_sq = x_sq[:, None] + z_sq[None, :] - 2 * np.dot(xi, zeta.T)
dist = np.sqrt(eps_distance + np.maximum(dist_sq, 0.0))
eta = eta - math.exp(params.tau) * dist
p = np.clip(1.0 / (1.0 + np.exp(-np.clip(eta, -700, 700))), 1e-12, 1 - 1e-12)
v = p * (1.0 - p)
resid2 = np.subtract(y, p)
np.square(resid2, out=resid2)
np.multiply(resid2, observed, out=resid2)
n_obs = np.maximum(observed.sum(axis=0), 1)

# Preserve the masked squared-residual numerator, then reuse its owned
# float64 buffer for the outfit division without a numeric mask copy.
resid2_sum = resid2.sum(axis=0)
infit_denominator = np.sum(v, axis=0, where=observed)
np.divide(resid2, v, out=resid2)
outfit = resid2.sum(axis=0) / n_obs
infit = resid2_sum / np.maximum(infit_denominator, 1e-12)
return {"infit": infit, "outfit": outfit}
if core is None or not hasattr(core, "infit_outfit_stat"):
raise RuntimeError("fit statistics require the compiled Rust core")
n_persons = y.shape[0]
n_dims = int(d_of_i.max()) + 1
bank = _bank_args(params, d_of_i, model, n_dims, eps_distance)
res = core.infit_outfit_stat(
y.ravel(),
observed.ravel(),
int(n_persons),
bank["alpha"],
bank["b"],
bank["zeta"],
bank["tau"],
bank["factor_id"],
bank["model"],
bank["n_dims"],
bank["latent_dim"],
bank["eps_distance"],
np.asarray(params.theta, dtype=np.float64).ravel(),
np.asarray(params.xi, dtype=np.float64).ravel(),
)
return {"infit": np.asarray(res["infit"]), "outfit": np.asarray(res["outfit"])}



# --------------------------------------------------------------------------
Expand Down Expand Up @@ -1851,56 +1829,44 @@ def m2(
)

core = _core_module()
if core is not None and hasattr(core, "m2_stat"):
bank = _bank_args(params, d_of_i, model, n_dims, eps_distance)
res = core.m2_stat(
np.where(observed0, y0, 0.0).ravel(),
observed0.ravel(),
int(y0.shape[0]),
bank["alpha"],
bank["b"],
bank["zeta"],
bank["tau"],
bank["factor_id"],
bank["model"],
bank["n_dims"],
bank["latent_dim"],
bank["eps_distance"],
prior_mean,
prior_sd,
q_theta=int(q_theta),
xi_rule="gh",
q_xi=int(q_xi),
)
result = M2Result(
m2=float(res["m2"]),
df=float(res["df"]),
p_value=float(res["p_value"]),
rmsea2=float(res["rmsea2"]),
rmsea2_ci_lower=float(res["rmsea2_ci_lower"]),
rmsea2_ci_upper=float(res["rmsea2_ci_upper"]),
srmsr=float(res["srmsr"]),
null_m2=float(res["null_m2"]),
null_df=float(res["null_df"]),
cfi=float(res["cfi"]),
tli=float(res["tli"]),
n_moments=int(res["n_moments"]),
n_parameters=int(res["n_parameters"]),
n_complete=int(res["n_complete"]),
)
else:
result = _m2_numpy(
y0,
observed0,
d_of_i,
params,
model,
q_theta,
q_xi,
eps_distance,
prior_mean,
prior_sd,
)
if core is None or not hasattr(core, "m2_stat"):
raise RuntimeError("fit statistics require the compiled Rust core")
bank = _bank_args(params, d_of_i, model, n_dims, eps_distance)
res = core.m2_stat(
np.where(observed0, y0, 0.0).ravel(),
observed0.ravel(),
int(y0.shape[0]),
bank["alpha"],
bank["b"],
bank["zeta"],
bank["tau"],
bank["factor_id"],
bank["model"],
bank["n_dims"],
bank["latent_dim"],
bank["eps_distance"],
prior_mean,
prior_sd,
q_theta=int(q_theta),
xi_rule="gh",
q_xi=int(q_xi),
)
result = M2Result(
m2=float(res["m2"]),
df=float(res["df"]),
p_value=float(res["p_value"]),
rmsea2=float(res["rmsea2"]),
rmsea2_ci_lower=float(res["rmsea2_ci_lower"]),
rmsea2_ci_upper=float(res["rmsea2_ci_upper"]),
srmsr=float(res["srmsr"]),
null_m2=float(res["null_m2"]),
null_df=float(res["null_df"]),
cfi=float(res["cfi"]),
tli=float(res["tli"]),
n_moments=int(res["n_moments"]),
n_parameters=int(res["n_parameters"]),
n_complete=int(res["n_complete"]),
)
if estimator == "mmle":
return result
result.estimator = estimator
Expand Down
16 changes: 13 additions & 3 deletions python/fast_mlsirm/rubric/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,20 @@ def _bounded_values(
raise ValueError(f"{name} must be a collection")
try:
iterator = iter(values)
except TypeError as exc:
raise ValueError(f"{name} must be a collection") from exc
except MemoryError:
raise
except Exception:
raise ValueError(f"{name} must be a collection") from None
materialized: list[Any] = []
for index, value in enumerate(iterator):
for index in range(maximum + 1):
try:
value = next(iterator)
except StopIteration:
break
except MemoryError:
raise
except Exception:
raise ValueError(f"{name} iteration failed") from None
if index >= maximum:
raise ValueError(f"{name} must contain at most {maximum} values")
materialized.append(value)
Expand Down
23 changes: 18 additions & 5 deletions tests/test_architecture_documentation_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,17 @@ def test_documentation_index_and_completeness_matrix_cover_security_and_gaps() -
"traceability/requirements-matrix.md",
):
assert target in index
for state in ("IMPLEMENTED", "ACTIVE PR", "PLANNED", "DOWNSTREAM"):
for state in (
"IMPLEMENTED_ON_PROTECTED_MAIN",
"IMPLEMENTED_ON_ACTIVE_PR",
"PLANNED",
"DOWNSTREAM",
"PRESENT_CURRENT",
"OWNED_BY_ACTIVE_PR",
):
assert state in coverage
assert "P0 documentation gaps" in coverage
assert "Canonical PyO3/public-export registry" in coverage
assert "Canonical PyO3/public-export governance" in coverage


def test_standards_watch_separates_published_sources_from_watch_items() -> None:
Expand Down Expand Up @@ -268,8 +275,14 @@ def test_documentation_contract_distinguishes_implemented_rotation_from_active_w

assert "Accepted CPU baseline / planned GPU and broader recovery extensions" in trace
assert "Proposed/partial / active PR" in trace
assert "IMPLEMENTED / PLANNED extensions" in coverage
assert "ACTIVE PR" in coverage
assert "PLANNED/partial" in coverage
rotation_row = next(
line
for line in coverage.splitlines()
if line.startswith("| Adaptive rotation criterion selection |")
)
assert "IMPLEMENTED_ON_PROTECTED_MAIN" in rotation_row
assert "PARTIAL" in rotation_row
assert "IMPLEMENTED_ON_ACTIVE_PR" not in rotation_row
assert "PLANNED" not in rotation_row
assert "Status: **Accepted**" in rotation_adr
assert "GPU/additional-criterion/recovery expansion remains planned" in rotation_adr
Loading
Loading