Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
33ce556
test: reject hidden pytest skip outcomes
seonghobae Sep 2, 2026
3131f27
test: fail closed on skipped pytest outcomes
seonghobae Sep 2, 2026
9b44a76
docs: record fail-closed pytest evidence
seonghobae Sep 2, 2026
8380151
test: expose non-execution counts in pytest logs
seonghobae Sep 2, 2026
1adf41e
test: preserve primary pytest failure status
seonghobae Sep 2, 2026
cbe0d83
fix: preserve primary pytest failure classification
seonghobae Sep 2, 2026
c2d4ca1
test: classify atomic-write capability without skipping
seonghobae Sep 3, 2026
e913a0d
test: require Rust parity capability instead of skipping
seonghobae Sep 3, 2026
de7bf9d
test: require native multigroup Rust evidence
seonghobae Sep 3, 2026
85ed9ee
test: require native multilevel Rust evidence
seonghobae Sep 3, 2026
d86a48b
test: make marginal parity capability evidence explicit
seonghobae Sep 3, 2026
42b723b
test: require Rust CLI backend evidence
seonghobae Sep 3, 2026
4e788a6
test: require marginal ABI capability evidence
seonghobae Sep 3, 2026
685aaaa
test: require Rust fit-pipeline evidence
seonghobae Sep 3, 2026
71d8646
test: require Rust JMLE ownership evidence
seonghobae Sep 3, 2026
deb5bb9
test: require production Rust backend evidence
seonghobae Sep 3, 2026
b34c2c4
test: require Rust robustness parity evidence
seonghobae Sep 3, 2026
eb04946
test: prove missing atomic-write capability fails closed
seonghobae Sep 3, 2026
5dc238d
test: make atomic-write capability RED deterministic
seonghobae Sep 3, 2026
3ab1ebf
test: fail closed when atomic-write primitives are unavailable
seonghobae Sep 3, 2026
c17462e
docs: record fail-closed atomic-write capability evidence
seonghobae Sep 3, 2026
1a75be8
test: prove late plugin cannot erase non-execution failure
seonghobae Sep 3, 2026
6430de9
fix: make pytest non-execution enforcement final
seonghobae Sep 3, 2026
b66a970
docs: record final pytest outcome enforcement
seonghobae Sep 3, 2026
10b1e5d
test: fail closed on missing extended precision
seonghobae Sep 3, 2026
3c1ed4e
test: fail closed on missing longdouble evidence
seonghobae Sep 3, 2026
c9adf57
docs: record extended-precision fail-closed evidence
seonghobae Sep 3, 2026
584d213
test: fail closed on missing WLE precision evidence
seonghobae Sep 3, 2026
f928b17
docs: include WLE precision fail-closed evidence
seonghobae Sep 3, 2026
e169e93
test: fail closed on missing CDM precision evidence
seonghobae Sep 3, 2026
1bb4a27
docs: include CDM precision fail-closed evidence
seonghobae Sep 3, 2026
102e116
test: fail closed on missing 2PL precision evidence
seonghobae Sep 3, 2026
07cc438
docs: include 2PL precision fail-closed evidence
seonghobae Sep 3, 2026
bf15b62
test(personfit): require Monte Carlo acceptance execution
seonghobae Sep 7, 2026
1d3016b
fix(personfit): execute deterministic Monte Carlo acceptance
seonghobae Sep 7, 2026
f7ecedd
docs(changelog): record person-fit Monte Carlo execution repair
seonghobae Sep 7, 2026
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/1732-fail-closed-pytest-outcomes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Fail-closed pytest outcome accounting

## Fixed

- Make repository pytest evidence non-passing whenever collection or execution records a skipped, expected-failure, or unexpected-pass outcome, so missing Rust/GPU/platform capability cannot be reported as a successful scientific or package gate. Clean all-executed runs retain their original status, and missing terminal outcome accounting fails closed.
- Make descriptor-relative atomic-write capability evidence fail closed when any required POSIX primitive is unavailable instead of returning normally from the three atomic-write tests and recording false passes.
- Enforce the non-execution verdict after ordinary `pytest_sessionfinish` implementations complete, so a later plugin cannot overwrite skipped evidence back to a successful process exit; a pre-existing stronger non-success exit remains non-passing rather than being erased.
- Make extended-precision population-label, Brennan-Kane mastery-cut, WLE control-admission, CDM response-admission, and compensatory 2PL response-admission evidence fail explicitly when the host `longdouble` is not wider than binary64, instead of recording those repository-owned losslessness checks as passing skips.
- Execute the existing deterministic 500-replication person-fit U3 reversed-respondent acceptance in the normal Rust suite instead of leaving it behind `#[ignore]`; its fixed-seed design, n=60, I=20, production `person_fit_np` call, and >=95% detection criterion remain unchanged.
37 changes: 37 additions & 0 deletions tests/_outcome_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Fail-closed accounting for pytest outcomes that do not execute assertions."""

from __future__ import annotations

from collections.abc import Mapping, Sized

import pytest


_NON_EXECUTION_BUCKETS = ("skipped", "xfailed", "xpassed")


def _non_execution_counts(stats: Mapping[str, Sized]) -> dict[str, int]:
"""Return non-zero skip/xfail/xpass counts from pytest terminal statistics."""
return {
bucket: len(stats.get(bucket, ()))
for bucket in _NON_EXECUTION_BUCKETS
if len(stats.get(bucket, ())) > 0
}


def _format_non_execution_counts(counts: Mapping[str, int]) -> str:
"""Render deterministic terminal evidence for prohibited non-execution states."""
detail = ", ".join(f"{bucket}={counts[bucket]}" for bucket in _NON_EXECUTION_BUCKETS if bucket in counts)
return f"non-execution outcomes are non-passing: {detail}"


def _enforce_no_hidden_outcomes(session: object, terminalreporter: object | None) -> None:
"""Fail a successful invocation on non-execution without erasing stronger failures."""
if session.exitstatus != pytest.ExitCode.OK:
return
Comment thread
seonghobae marked this conversation as resolved.
if terminalreporter is None:
session.exitstatus = pytest.ExitCode.TESTS_FAILED
return
counts = _non_execution_counts(terminalreporter.stats)
if counts:
session.exitstatus = pytest.ExitCode.TESTS_FAILED
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
33 changes: 33 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Repository-wide pytest governance for fail-closed test execution."""

from __future__ import annotations

from collections.abc import Generator

import pytest

from _outcome_policy import (
_enforce_no_hidden_outcomes,
_format_non_execution_counts,
_non_execution_counts,
)


@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_sessionfinish(
session: object,
exitstatus: int,
) -> Generator[None, None, None]:
"""Enforce non-execution policy after every ordinary session-finish hook."""
original_exitstatus = exitstatus
yield

if original_exitstatus != pytest.ExitCode.OK and session.exitstatus == pytest.ExitCode.OK:
session.exitstatus = original_exitstatus

terminalreporter = session.config.pluginmanager.get_plugin("terminalreporter")
if session.exitstatus == pytest.ExitCode.OK and terminalreporter is not None:
counts = _non_execution_counts(terminalreporter.stats)
if counts:
terminalreporter.write_sep("=", _format_non_execution_counts(counts))
_enforce_no_hidden_outcomes(session, terminalreporter)
4 changes: 3 additions & 1 deletion tests/test_cdm_response_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ def test_cdm_fits_reject_extended_precision_that_collapses_to_binary(monkeypatch
"""A non-binary long-double value cannot round into accepted float64 evidence."""

if np.finfo(np.longdouble).eps >= np.finfo(np.float64).eps:
pytest.skip("platform longdouble does not exceed float64 precision")
pytest.fail(
"lossless CDM response-admission evidence requires longdouble wider than binary64"
)

monkeypatch.setattr(fitstats, "_core_module", _unexpected_core_discovery)
almost_one = np.nextafter(np.longdouble(1), np.longdouble(0))
Expand Down
5 changes: 2 additions & 3 deletions tests/test_cli_plain_mmle_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

from __future__ import annotations

from importlib import import_module
import json
import sys
from unittest.mock import patch

import pytest

from fast_mlsirm.cli import main


Expand All @@ -16,7 +15,7 @@ def test_cli_plain_unidimensional_mmle_auto_reports_rust(
) -> None:
"""Plain MMLE must report the resolved Rust backend, never the selector ``auto``."""

pytest.importorskip("fast_mlsirm._core")
import_module("fast_mlsirm._core")
monkeypatch.chdir(tmp_path)
sim_dir = tmp_path / "sim_out"
fit_dir = tmp_path / "fit_out"
Expand Down
63 changes: 47 additions & 16 deletions tests/test_enterprise_due_diligence_gate_atomic_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import importlib.util
import stat
import sys
from pathlib import Path
from types import ModuleType

Expand All @@ -24,20 +25,53 @@ def _load_module() -> ModuleType:
GATE = _load_module()


def _descriptor_write_prerequisites() -> dict[str, bool]:
"""Report each OS primitive required by descriptor-relative replacement."""
return {
"posix": GATE.os.name == "posix",
"open_dir_fd": GATE.os.open in GATE.os.supports_dir_fd,
"mkdir_dir_fd": GATE.os.mkdir in GATE.os.supports_dir_fd,
"rename_dir_fd": GATE.os.rename in GATE.os.supports_dir_fd,
"unlink_dir_fd": GATE.os.unlink in GATE.os.supports_dir_fd,
"stat_dir_fd": GATE.os.stat in GATE.os.supports_dir_fd,
"fchmod": hasattr(GATE.os, "fchmod"),
"o_directory": hasattr(GATE.os, "O_DIRECTORY"),
"o_nofollow": hasattr(GATE.os, "O_NOFOLLOW"),
}


def _descriptor_writes_supported() -> bool:
return (
GATE.os.name == "posix"
and GATE.os.open in GATE.os.supports_dir_fd
and GATE.os.mkdir in GATE.os.supports_dir_fd
and GATE.os.rename in GATE.os.supports_dir_fd
and GATE.os.unlink in GATE.os.supports_dir_fd
and GATE.os.stat in GATE.os.supports_dir_fd
and hasattr(GATE.os, "fchmod")
and hasattr(GATE.os, "O_DIRECTORY")
and hasattr(GATE.os, "O_NOFOLLOW")
"""Return whether every descriptor-relative replacement primitive exists."""
return all(_descriptor_write_prerequisites().values())


def _assert_descriptor_write_capability() -> None:
"""Require every descriptor-relative replacement primitive."""
missing = tuple(
name
for name, available in _descriptor_write_prerequisites().items()
if not available
)
assert not missing, (
"descriptor-relative atomic-write prerequisites are unavailable: "
+ ", ".join(missing)
)


def test_descriptor_write_capability_fails_closed_when_prerequisite_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Missing atomic-write primitives must be failing evidence, never a passing return."""
monkeypatch.setattr(
sys.modules[__name__],
"_descriptor_write_prerequisites",
lambda: {"posix": False, "open_dir_fd": True},
)

with pytest.raises(AssertionError, match="posix"):
_assert_descriptor_write_capability()


def _permissions(path: Path) -> int:
return stat.S_IMODE(path.stat().st_mode)

Expand All @@ -47,8 +81,7 @@ def test_descriptor_write_failure_preserves_existing_manifest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failed descriptor write must not truncate the accepted manifest."""
if not _descriptor_writes_supported():
pytest.skip("descriptor-relative atomic replacement is unavailable")
_assert_descriptor_write_capability()

monkeypatch.chdir(tmp_path)
output_path = Path("secure") / "gate.json"
Expand Down Expand Up @@ -86,8 +119,7 @@ def test_descriptor_replacement_preserves_existing_manifest_permissions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Atomic replacement must retain an existing manifest's access contract."""
if not _descriptor_writes_supported():
pytest.skip("descriptor-relative atomic replacement is unavailable")
_assert_descriptor_write_capability()

monkeypatch.chdir(tmp_path)
output_path = Path("secure") / "gate.json"
Expand All @@ -108,8 +140,7 @@ def test_descriptor_new_manifest_uses_normal_creation_permissions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A new atomic manifest must retain ordinary file-creation permissions."""
if not _descriptor_writes_supported():
pytest.skip("descriptor-relative atomic replacement is unavailable")
_assert_descriptor_write_capability()

monkeypatch.chdir(tmp_path)
baseline = Path("baseline.json")
Expand Down
4 changes: 3 additions & 1 deletion tests/test_fit_pipeline.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from importlib import import_module

import numpy as np
import pytest

Expand Down Expand Up @@ -44,7 +46,7 @@ def test_auto_backend_requires_rust_core(monkeypatch):


def test_rust_backend_fit_smoke():
pytest.importorskip("fast_mlsirm._core")
import_module("fast_mlsirm._core")
data = simulate(MLS2PLMConfig(n_persons=12, n_dims=1, items_per_dim=2, latent_dim=1, seed=31))

result = fit(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_fitstats_multigroup_rust_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def test_projected_m2_native_rejects_nonfinite_inputs(target):
"""The PyO3 boundary must reject non-finite values before Rust arithmetic."""
core = fitstats._core_module()
if core is None:
pytest.skip("compiled Rust core is unavailable in this test environment")
pytest.fail("compiled Rust core is required for native projection evidence")

residual = np.array([0.25, -0.25], dtype=np.float64)
delta = np.array([[1.0], [0.5]], dtype=np.float64)
Expand All @@ -150,7 +150,7 @@ def test_projected_m2_native_rejects_oversized_broadcast_before_copy():
"""Logical broadcast shapes must hit the resource guard before materialization."""
core = fitstats._core_module()
if core is None:
pytest.skip("compiled Rust core is unavailable in this test environment")
pytest.fail("compiled Rust core is required for native projection evidence")

size = 2049
residual = np.zeros(size, dtype=np.float64)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_fitstats_multilevel_rust_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def test_native_multilevel_moments_match_the_reference_reduction():
"""Rust multilevel moment integration matches the paper-backed reference path."""
core = fitstats._core_module()
if core is None:
pytest.skip("compiled Rust core is unavailable in this test environment")
pytest.fail("compiled Rust core is required for multilevel moment parity")

probs = np.array(
[
Expand Down Expand Up @@ -193,7 +193,7 @@ def test_native_cluster_covariance_matches_explicit_cluster_reference():
"""Rust cluster covariance preserves finite-cluster correction and centering."""
core = fitstats._core_module()
if core is None:
pytest.skip("compiled Rust core is unavailable in this test environment")
pytest.fail("compiled Rust core is required for cluster covariance parity")

rows = np.array(
[[1.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0], [1.0, 1.0], [1.0, 1.0]]
Expand Down
4 changes: 3 additions & 1 deletion tests/test_gtheory_cut_lossless.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ def test_phi_lambda_rejects_lossy_longdouble_cut_before_native_discovery(
) -> None:
"""Extended-precision mastery cuts cannot be silently rounded to Rust f64."""
if np.finfo(np.longdouble).nmant <= np.finfo(np.float64).nmant:
pytest.skip("platform longdouble has no precision beyond float64")
pytest.fail(
"lossless mastery-cut evidence requires longdouble wider than binary64"
)

core_calls: list[str] = []

Expand Down
2 changes: 1 addition & 1 deletion tests/test_jmle_rust_optimizer_ownership.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_public_jmle_optimization_does_not_execute_python_optimizer_loops(
optimizer: str,
) -> None:
"""Installed Rust-backed JMLE must not delegate optimizer arithmetic to Python."""
pytest.importorskip("fast_mlsirm._core")
importlib.import_module("fast_mlsirm._core")
data = simulate(
MLS2PLMConfig(
n_persons=12,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_marginal_mmle_rust_capability_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def reject_numpy_reference(

def test_compiled_rust_core_exports_current_marginal_capability() -> None:
"""The native module publishes the exact Python-supported ABI version."""
core = pytest.importorskip("fast_mlsirm._core")
core = importlib.import_module("fast_mlsirm._core")
capability = core.MARGINAL_CAPABILITY_VERSION
assert type(capability) is int
assert capability == fit_module._MARGINAL_CAPABILITY_VERSION
21 changes: 15 additions & 6 deletions tests/test_marginal_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@

from __future__ import annotations

from importlib import import_module

import numpy as np
import pytest

from fast_mlsirm.config import FitConfig
from fast_mlsirm.fit import fit
from fast_mlsirm.reference import fit_reference

pytestmark = pytest.mark.skipif(
pytest.importorskip("fast_mlsirm._core", reason="compiled core required") is None,
reason="compiled core required",
)
# Marginal parity is release evidence for the compiled numerical owner. Missing
# native capability is therefore a hard collection error, not a passing skip.
import_module("fast_mlsirm._core")


def _simulate(seed=0, n_persons=250, n_items=12, n_dims=2, latent_dim=2, missing=0.0):
Expand Down Expand Up @@ -138,10 +139,18 @@ def test_marginal_gpu_agrees_with_cpu_loosely(capfd):
)
results[device] = fit(y, fid, cfg, cluster_id=cluster_id)
device_stderr = capfd.readouterr().err
if "no usable GPU adapter was found" in device_stderr:
pytest.skip("no usable GPU adapter; explicit GPU request fell back to CPU")

r, g = results["cpu"], results["gpu"]
if "no usable GPU adapter was found" in device_stderr:
# A CPU-only host cannot provide GPU parity evidence, but it must prove
# the documented fallback is deterministic rather than hiding the lane.
np.testing.assert_allclose(g.params.b, r.params.b, atol=1e-12)
np.testing.assert_allclose(g.params.zeta, r.params.zeta, atol=1e-12)
np.testing.assert_allclose(g.params.theta, r.params.theta, atol=1e-12)
np.testing.assert_allclose(g.population["sigma_u"], r.population["sigma_u"], atol=1e-12)
np.testing.assert_allclose(g.loglik_trace[-1], r.loglik_trace[-1], rtol=0, atol=1e-12)
return
Comment thread
seonghobae marked this conversation as resolved.

np.testing.assert_allclose(g.params.b, r.params.b, atol=1e-3)
np.testing.assert_allclose(g.params.zeta, r.params.zeta, atol=5e-3)
np.testing.assert_allclose(g.params.theta, r.params.theta, atol=1e-3)
Expand Down
8 changes: 5 additions & 3 deletions tests/test_missing_and_extreme_robustness.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@

from __future__ import annotations

from importlib import import_module

import numpy as np
import pytest

Expand Down Expand Up @@ -118,7 +120,7 @@ def test_constant_items_yield_finite_objective(backend):
still be handled gracefully rather than dividing by a zero variance.
"""
if backend == "rust":
pytest.importorskip("fast_mlsirm._core")
import_module("fast_mlsirm._core")
# Column 0 is constant 0; column 3 is constant 1.
responses = np.array(
[
Expand Down Expand Up @@ -201,7 +203,7 @@ def test_missing_sentinels_are_equivalent_and_masked_entries_do_not_contribute(b
nothing. Verified on both backends (Rubin, 1976; Bock & Aitkin, 1981).
"""
if backend == "rust":
pytest.importorskip("fast_mlsirm._core")
import_module("fast_mlsirm._core")
base, missing, params, factors, config = _missing_fixture()

y_nan = base.copy()
Expand Down Expand Up @@ -233,7 +235,7 @@ def test_rust_and_numpy_agree_on_masked_inputs():
"""Rust and NumPy backends agree on masked inputs (extends the parity
invariant of ``test_rust_parity.py`` to the NaN-sentinel + explicit-mask
combination on a latent-space model)."""
pytest.importorskip("fast_mlsirm._core")
import_module("fast_mlsirm._core")
base, missing, params, factors, config = _missing_fixture()
y_nan = base.copy()
y_nan[missing] = np.nan
Expand Down
Loading
Loading