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
7 changes: 7 additions & 0 deletions docs/changelog.d/787-item-bank-dif-applicability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Item-bank DIF applicability evidence

## Fixed

- Calibration transitions accept either DIF evidence or explicit
`dif_not_applicable` evidence, forbid both at once, and keep other lifecycle
gates unchanged.
18 changes: 17 additions & 1 deletion python/fast_mlsirm/rubric/item_bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class ItemBankEvidenceKind(str, Enum):
CALIBRATION = "calibration"
ITEM_FIT = "item_fit"
DIF = "dif"
DIF_NOT_APPLICABLE = "dif_not_applicable"
ITEM_INFORMATION = "item_information"
LINKING = "linking"
EXPOSURE = "exposure"
Expand Down Expand Up @@ -504,9 +505,14 @@ def _missing_required_kinds(
required = {
ItemBankEvidenceKind.CALIBRATION,
ItemBankEvidenceKind.ITEM_FIT,
ItemBankEvidenceKind.DIF,
ItemBankEvidenceKind.ITEM_INFORMATION,
}
missing = [kind.value for kind in required - supplied_kinds]
if not supplied_kinds.intersection(
{ItemBankEvidenceKind.DIF, ItemBankEvidenceKind.DIF_NOT_APPLICABLE}
):
missing.append("dif_or_dif_not_applicable")
return tuple(sorted(missing))
elif target_state is ItemBankLifecycleState.APPROVED:
required = {ItemBankEvidenceKind.APPROVAL}
elif (
Expand Down Expand Up @@ -561,6 +567,16 @@ def transition_item_bank_record(
error_type=ItemBankLifecycleError,
)
supplied_kinds = {reference.evidence_kind for reference in additions}
if (
target is ItemBankLifecycleState.CALIBRATED
and ItemBankEvidenceKind.DIF in supplied_kinds
and ItemBankEvidenceKind.DIF_NOT_APPLICABLE in supplied_kinds
):
raise ItemBankLifecycleError(
"conflicting_dif_applicability",
"$.evidence_references",
"calibration requires exactly one DIF applicability evidence class",
)
missing = _missing_required_kinds(
current.lifecycle_state,
target,
Expand Down
136 changes: 136 additions & 0 deletions tests/test_item_bank_dif_applicability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Contracts for scientifically explicit DIF applicability evidence."""

from __future__ import annotations

from pathlib import Path
import runpy

import pytest

from fast_mlsirm.rubric.item_bank import (
ItemBankEvidenceKind,
ItemBankEvidenceReference,
ItemBankLifecycleError,
ItemBankLifecycleState,
build_item_bank_pilot_record,
transition_item_bank_record,
)

_LIFECYCLE_FIXTURES = runpy.run_path(
str(Path(__file__).with_name("test_rubric_item_bank_lifecycle.py"))
)


def _evidence(
kind: ItemBankEvidenceKind,
suffix: str,
fingerprint_character: str,
) -> ItemBankEvidenceReference:
"""Return one bounded source-text-free lifecycle evidence identity."""
return ItemBankEvidenceReference(
evidence_kind=kind,
evidence_id=f"{suffix}_evidence",
evidence_fingerprint=fingerprint_character * 64,
)


def _pilot_record():
"""Return one verified pilot lifecycle record from canonical test fixtures."""
pilot = _LIFECYCLE_FIXTURES["_pilot_record"]()
return build_item_bank_pilot_record(pilot, item_version="1.0.0")


def _base_calibration_evidence() -> tuple[ItemBankEvidenceReference, ...]:
"""Return calibration evidence common to both DIF applicability branches."""
return (
_evidence(ItemBankEvidenceKind.CALIBRATION, "calibration", "a"),
_evidence(ItemBankEvidenceKind.ITEM_FIT, "item_fit", "b"),
_evidence(ItemBankEvidenceKind.ITEM_INFORMATION, "item_information", "c"),
)


def test_item_bank_evidence_domain_can_represent_dif_not_applicable() -> None:
"""Calibration must not require fabricated DIF when no comparison design exists."""
evidence_kinds = {kind.value for kind in ItemBankEvidenceKind}

assert "dif_not_applicable" in evidence_kinds


def test_calibration_accepts_governed_dif_not_applicable_evidence() -> None:
"""A governed N/A determination can satisfy only the DIF applicability gate."""
calibrated = transition_item_bank_record(
_pilot_record(),
ItemBankLifecycleState.CALIBRATED,
evidence_references=(
*_base_calibration_evidence(),
_evidence(
ItemBankEvidenceKind.DIF_NOT_APPLICABLE,
"dif_not_applicable",
"d",
),
),
transition_reason_id="calibration_completed",
)

assert calibrated.lifecycle_state is ItemBankLifecycleState.CALIBRATED
assert ItemBankEvidenceKind.DIF_NOT_APPLICABLE in {
reference.evidence_kind for reference in calibrated.evidence_references
}
assert ItemBankEvidenceKind.DIF not in {
reference.evidence_kind for reference in calibrated.evidence_references
}


def test_calibration_still_requires_an_explicit_dif_applicability_decision() -> None:
"""Omitting both measured DIF and governed N/A evidence fails closed."""
with pytest.raises(ItemBankLifecycleError) as caught:
transition_item_bank_record(
_pilot_record(),
ItemBankLifecycleState.CALIBRATED,
evidence_references=_base_calibration_evidence(),
transition_reason_id="calibration_completed",
)

assert caught.value.code == "missing_transition_evidence"
assert caught.value.path == "$.evidence_references"
assert "dif_or_dif_not_applicable" in caught.value.message


def test_calibration_rejects_conflicting_dif_applicability_evidence() -> None:
"""A calibration cannot claim both measured DIF and DIF-not-applicable."""
with pytest.raises(ItemBankLifecycleError) as caught:
transition_item_bank_record(
_pilot_record(),
ItemBankLifecycleState.CALIBRATED,
evidence_references=(
*_base_calibration_evidence(),
_evidence(ItemBankEvidenceKind.DIF, "dif", "d"),
_evidence(
ItemBankEvidenceKind.DIF_NOT_APPLICABLE,
"dif_not_applicable",
"e",
),
),
transition_reason_id="calibration_completed",
)

assert caught.value.code == "conflicting_dif_applicability"
assert caught.value.path == "$.evidence_references"


def test_existing_measured_dif_calibration_path_is_unchanged() -> None:
"""A comparison design with real DIF evidence retains the prior contract."""
calibrated = transition_item_bank_record(
_pilot_record(),
ItemBankLifecycleState.CALIBRATED,
evidence_references=(
*_base_calibration_evidence(),
_evidence(ItemBankEvidenceKind.DIF, "dif", "d"),
),
transition_reason_id="calibration_completed",
)

assert calibrated.lifecycle_state is ItemBankLifecycleState.CALIBRATED
assert ItemBankEvidenceKind.DIF in {
reference.evidence_kind for reference in calibrated.evidence_references
}
Loading