From 721919dc838ead5371b88b76d441dba75596f15a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:03:09 +0900 Subject: [PATCH] rebuild: #796 unique product delta on current main --- CHANGELOG.md | 13 ++ .../787-item-bank-dif-applicability.md | 7 + python/fast_mlsirm/rubric/item_bank.py | 18 ++- tests/test_item_bank_dif_applicability.py | 136 ++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/787-item-bank-dif-applicability.md create mode 100644 tests/test_item_bank_dif_applicability.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bdeeeb7..c6986b7ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -552,12 +552,25 @@ vocabulary and mark parallel-analysis control bounds and essay-report native dark-mode accents as ancestral after their integration. +#### Retire competing hourly review-repair caller + +- Remove the repository-local hourly review-repair GitHub Actions caller so only + the organization single-writer control plane schedules mutation loops, matching + ADR-0013 continuous-execution governance after failed startup evidence for the + local caller. + #### Multilevel hostile numeric callback rejection - Multilevel membership weights and AR(1) coefficients now admit only exact built-in `int`/`float` scalars, rejecting booleans and caller-defined conversion hooks before contract arithmetic. +#### Item-bank DIF applicability evidence + +- Calibration transitions accept either DIF evidence or explicit + `dif_not_applicable` evidence, forbid both at once, and keep other lifecycle + gates unchanged. + #### 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. diff --git a/docs/changelog.d/787-item-bank-dif-applicability.md b/docs/changelog.d/787-item-bank-dif-applicability.md new file mode 100644 index 000000000..7f8efd3b5 --- /dev/null +++ b/docs/changelog.d/787-item-bank-dif-applicability.md @@ -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. diff --git a/python/fast_mlsirm/rubric/item_bank.py b/python/fast_mlsirm/rubric/item_bank.py index f1077a122..a92bd87c7 100644 --- a/python/fast_mlsirm/rubric/item_bank.py +++ b/python/fast_mlsirm/rubric/item_bank.py @@ -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" @@ -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 ( @@ -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, diff --git a/tests/test_item_bank_dif_applicability.py b/tests/test_item_bank_dif_applicability.py new file mode 100644 index 000000000..48248857a --- /dev/null +++ b/tests/test_item_bank_dif_applicability.py @@ -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 + }