From f926e5c04d390469e8a34d93db624217e3931b11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:50:51 +0900 Subject: [PATCH 01/18] test(measurement): define dichotomous policy contract --- tests/test_measurement_policy.py | 96 ++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_measurement_policy.py diff --git a/tests/test_measurement_policy.py b/tests/test_measurement_policy.py new file mode 100644 index 000000000..d1ba4e1ae --- /dev/null +++ b/tests/test_measurement_policy.py @@ -0,0 +1,96 @@ +"""Contracts for LineageWeave-owned measurement policy, not numerical estimation.""" + +from __future__ import annotations + +import pytest + +from lineageweave.measurement_policy import ( + DichotomousObservation, + DichotomousObservationState, + MeasurementDomain, + MeasurementModelFamily, + default_dichotomous_model_family, +) + + +def test_production_model_families_keep_rasch_distinct_from_generic_1pl() -> None: + """Public model identifiers expose the governed families and no generic 1PL.""" + assert {family.value for family in MeasurementModelFamily} == { + "rasch", + "irt_2plm", + "irt_3plm", + "irt_4plm", + } + + +def test_observed_dichotomous_response_accepts_only_zero_or_one() -> None: + """An observed criterion result is binary; missing-like states are not scores.""" + assert DichotomousObservation.observed(0).response == 0 + assert DichotomousObservation.observed(1).response == 1 + + for invalid in (-1, 2, True, False): + with pytest.raises(ValueError, match="0 or 1"): + DichotomousObservation.observed(invalid) # type: ignore[arg-type] + + +def test_nonobserved_states_never_carry_a_binary_response() -> None: + """Missing, abstain and adjudication states remain outside the 0/1 channel.""" + for state in ( + DichotomousObservationState.MISSING, + DichotomousObservationState.NOT_OBSERVABLE, + DichotomousObservationState.ABSTAIN, + DichotomousObservationState.INVALID_EVIDENCE, + DichotomousObservationState.ADJUDICATION_REQUIRED, + ): + observation = DichotomousObservation.unscored(state) + assert observation.response is None + assert observation.state is state + + with pytest.raises(ValueError, match="observed state"): + DichotomousObservation.unscored(DichotomousObservationState.OBSERVED) + + +def test_education_requires_an_explicit_rasch_or_guessing_mechanism() -> None: + """Educational use never falls back silently to generic one-parameter IRT.""" + assert ( + default_dichotomous_model_family( + MeasurementDomain.EDUCATIONAL, + rasch_requirements_intended=True, + ) + is MeasurementModelFamily.RASCH + ) + assert ( + default_dichotomous_model_family( + MeasurementDomain.EDUCATIONAL, + lower_asymptote_justified=True, + ) + is MeasurementModelFamily.IRT_3PLM + ) + assert default_dichotomous_model_family(MeasurementDomain.EDUCATIONAL) is None + + +def test_psychology_defaults_to_2plm_when_discrimination_may_vary() -> None: + """Psychology/SEM-lineage policy uses 2PLM as the default logistic family.""" + assert ( + default_dichotomous_model_family(MeasurementDomain.PSYCHOLOGY_SEM) + is MeasurementModelFamily.IRT_2PLM + ) + + +def test_gambling_risk_requires_both_asymptote_mechanisms_for_4plm() -> None: + """4PLM is selected only when lower and upper asymptotes are both justified.""" + assert ( + default_dichotomous_model_family( + MeasurementDomain.GAMBLING_GAMING_RISK, + lower_asymptote_justified=True, + upper_asymptote_justified=True, + ) + is MeasurementModelFamily.IRT_4PLM + ) + assert ( + default_dichotomous_model_family( + MeasurementDomain.GAMBLING_GAMING_RISK, + lower_asymptote_justified=True, + ) + is None + ) From b2164af34183cd9de956add7408f8a1f2d9b5558 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:51:08 +0900 Subject: [PATCH 02/18] feat(measurement): add fail-closed dichotomous policy contract --- lineageweave/measurement_policy.py | 113 +++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 lineageweave/measurement_policy.py diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py new file mode 100644 index 000000000..bd38b1c9b --- /dev/null +++ b/lineageweave/measurement_policy.py @@ -0,0 +1,113 @@ +"""Version-stable LineageWeave measurement-policy vocabulary. + +This module owns product policy only. It deliberately contains no IRT fitting, +parameter estimation, judge routing, provider selection, or temporal analysis. +Those responsibilities stay with fast-mlsirm, contextual-orchestrator, and TEPP. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class MeasurementModelFamily(StrEnum): + """Production model families allowed by the current measurement policy. + + Rasch is intentionally a distinct family identifier. Generic one-parameter + logistic IRT is not exposed as a normal production choice. + """ + + RASCH = "rasch" + IRT_2PLM = "irt_2plm" + IRT_3PLM = "irt_3plm" + IRT_4PLM = "irt_4plm" + + +class MeasurementDomain(StrEnum): + """Domain contexts that determine the default dichotomous model policy.""" + + EDUCATIONAL = "educational" + PSYCHOLOGY_SEM = "psychology_sem" + GAMBLING_GAMING_RISK = "gambling_gaming_risk" + + +class DichotomousObservationState(StrEnum): + """State of one rubric-governed dichotomous observation.""" + + OBSERVED = "observed" + MISSING = "missing" + NOT_OBSERVABLE = "not_observable" + ABSTAIN = "abstain" + INVALID_EVIDENCE = "invalid_evidence" + ADJUDICATION_REQUIRED = "adjudication_required" + + +@dataclass(frozen=True, slots=True) +class DichotomousObservation: + """One 0/1 criterion observation or an explicitly unscored state. + + ``response=0`` means the versioned support criterion was not satisfied and + ``response=1`` means it was satisfied. A response is never used to encode + missingness, abstention, invalid evidence, or an unresolved adjudication. + """ + + state: DichotomousObservationState + response: int | None + + def __post_init__(self) -> None: + if self.state is DichotomousObservationState.OBSERVED: + if type(self.response) is not int or self.response not in (0, 1): + raise ValueError("observed dichotomous response must be integer 0 or 1") + return + if self.response is not None: + raise ValueError("unscored dichotomous states must not carry a 0/1 response") + + @classmethod + def observed(cls, response: int) -> "DichotomousObservation": + """Create an observed 0/1 response under the instrument's rubric.""" + return cls(DichotomousObservationState.OBSERVED, response) + + @classmethod + def unscored( + cls, + state: DichotomousObservationState, + ) -> "DichotomousObservation": + """Create a non-response state that remains outside the binary channel.""" + if state is DichotomousObservationState.OBSERVED: + raise ValueError("observed state requires an explicit 0 or 1 response") + return cls(state, None) + + +def default_dichotomous_model_family( + domain: MeasurementDomain, + *, + rasch_requirements_intended: bool = False, + lower_asymptote_justified: bool = False, + upper_asymptote_justified: bool = False, +) -> MeasurementModelFamily | None: + """Return the governed default family when its mechanism is explicit. + + ``None`` is a deliberate fail-closed result: the observations may be kept, + but LineageWeave must not invent a latent score until a scientifically + defensible model is selected and activated. This function selects policy; + it performs no numerical psychometric computation. + """ + if domain is MeasurementDomain.EDUCATIONAL: + if rasch_requirements_intended: + return MeasurementModelFamily.RASCH + if lower_asymptote_justified and not upper_asymptote_justified: + return MeasurementModelFamily.IRT_3PLM + return None + + if domain is MeasurementDomain.PSYCHOLOGY_SEM: + if rasch_requirements_intended or lower_asymptote_justified or upper_asymptote_justified: + return None + return MeasurementModelFamily.IRT_2PLM + + if domain is MeasurementDomain.GAMBLING_GAMING_RISK: + if lower_asymptote_justified and upper_asymptote_justified: + return MeasurementModelFamily.IRT_4PLM + return None + + return None From 6c8ef074cb900f84a7065dd333e1afe8c7beec34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:51:44 +0900 Subject: [PATCH 03/18] test(measurement): fail closed before instrument activation --- tests/test_measurement_policy.py | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_measurement_policy.py b/tests/test_measurement_policy.py index d1ba4e1ae..498933bdb 100644 --- a/tests/test_measurement_policy.py +++ b/tests/test_measurement_policy.py @@ -5,8 +5,11 @@ import pytest from lineageweave.measurement_policy import ( + DichotomousItemPolicy, DichotomousObservation, DichotomousObservationState, + InstrumentLifecycle, + InstrumentMeasurementPolicy, MeasurementDomain, MeasurementModelFamily, default_dichotomous_model_family, @@ -50,6 +53,67 @@ def test_nonobserved_states_never_carry_a_binary_response() -> None: DichotomousObservation.unscored(DichotomousObservationState.OBSERVED) +def test_item_policy_names_both_sides_of_the_binary_rubric() -> None: + """Zero and one encode explicit rubric criteria instead of score compression.""" + policy = DichotomousItemPolicy( + item_id="importance-evidence-1", + rubric_version="2026-09-01", + not_supported_criterion="Evidence does not establish the stated criterion.", + supported_criterion="Evidence establishes the stated criterion.", + ) + assert policy.not_supported_criterion != policy.supported_criterion + + with pytest.raises(ValueError, match="distinct"): + DichotomousItemPolicy( + item_id="importance-evidence-1", + rubric_version="2026-09-01", + not_supported_criterion="same", + supported_criterion="same", + ) + + +def test_pilot_instrument_may_preserve_observations_without_a_latent_model() -> None: + """Pilot data are valid evidence even when no scoring model is defensible yet.""" + policy = InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PILOT, + model_family=None, + activation_evidence_ref=None, + ) + assert policy.model_family is None + + +def test_published_instrument_requires_model_and_activation_evidence() -> None: + """Operational scoring remains unavailable until model and evidence are bound.""" + with pytest.raises(ValueError, match="activation evidence"): + InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PUBLISHED, + model_family=MeasurementModelFamily.IRT_2PLM, + activation_evidence_ref=None, + ) + + with pytest.raises(ValueError, match="model family"): + InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PUBLISHED, + model_family=None, + activation_evidence_ref="evidence://pilot/2026-09", + ) + + policy = InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PUBLISHED, + model_family=MeasurementModelFamily.IRT_2PLM, + activation_evidence_ref="evidence://pilot/2026-09", + ) + assert policy.lifecycle is InstrumentLifecycle.PUBLISHED + + def test_education_requires_an_explicit_rasch_or_guessing_mechanism() -> None: """Educational use never falls back silently to generic one-parameter IRT.""" assert ( From d3f8bd021d5f9ea0afbd03ec827c87461cfb521e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:52:02 +0900 Subject: [PATCH 04/18] feat(measurement): bind rubric and activation policy --- lineageweave/measurement_policy.py | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index bd38b1c9b..32b825931 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -32,6 +32,15 @@ class MeasurementDomain(StrEnum): GAMBLING_GAMING_RISK = "gambling_gaming_risk" +class InstrumentLifecycle(StrEnum): + """Governed lifecycle of a versioned measurement instrument.""" + + DRAFT = "draft" + PILOT = "pilot" + PUBLISHED = "published" + RETIRED = "retired" + + class DichotomousObservationState(StrEnum): """State of one rubric-governed dichotomous observation.""" @@ -43,6 +52,58 @@ class DichotomousObservationState(StrEnum): ADJUDICATION_REQUIRED = "adjudication_required" +@dataclass(frozen=True, slots=True) +class DichotomousItemPolicy: + """Versioned rubric that gives the binary response an explicit meaning.""" + + item_id: str + rubric_version: str + not_supported_criterion: str + supported_criterion: str + + def __post_init__(self) -> None: + values = ( + self.item_id, + self.rubric_version, + self.not_supported_criterion, + self.supported_criterion, + ) + if any(not value.strip() for value in values): + raise ValueError("dichotomous item policy fields must be non-empty") + if self.not_supported_criterion.strip() == self.supported_criterion.strip(): + raise ValueError("0 and 1 rubric criteria must be distinct") + + +@dataclass(frozen=True, slots=True) +class InstrumentMeasurementPolicy: + """Scoring activation contract for one immutable instrument revision. + + Draft and pilot instruments may preserve observations without a latent + model. A published instrument must bind both a governed model family and an + activation-evidence reference; otherwise operational latent scoring remains + unavailable. + """ + + instrument_id: str + revision: int + lifecycle: InstrumentLifecycle + model_family: MeasurementModelFamily | None + activation_evidence_ref: str | None + + def __post_init__(self) -> None: + if not self.instrument_id.strip(): + raise ValueError("instrument_id must be non-empty") + if type(self.revision) is not int or self.revision < 1: + raise ValueError("instrument revision must be a positive integer") + if self.activation_evidence_ref is not None and not self.activation_evidence_ref.strip(): + raise ValueError("activation evidence reference must be non-empty when supplied") + if self.lifecycle is InstrumentLifecycle.PUBLISHED: + if self.model_family is None: + raise ValueError("published instrument requires a measurement model family") + if self.activation_evidence_ref is None: + raise ValueError("published instrument requires activation evidence") + + @dataclass(frozen=True, slots=True) class DichotomousObservation: """One 0/1 criterion observation or an explicitly unscored state. From 454a0b3dc432e0b746a3ca524d3fccdc00d2b2fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:52:28 +0900 Subject: [PATCH 05/18] docs(adr): govern dichotomous measurement policy --- .../0301-dichotomous-measurement-policy.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/adr/0301-dichotomous-measurement-policy.md diff --git a/docs/adr/0301-dichotomous-measurement-policy.md b/docs/adr/0301-dichotomous-measurement-policy.md new file mode 100644 index 000000000..9b269a1d2 --- /dev/null +++ b/docs/adr/0301-dichotomous-measurement-policy.md @@ -0,0 +1,37 @@ +# ADR 0301: Govern dichotomous measurement policy at the LineageWeave boundary + +- Status: Proposed +- Date: 2026-09-01 +- Depends on: ADR 0300 (`contextual-orchestrator` ownership boundary) + +## Context + +LineageWeave owns instrument, rubric, evidence-binding, pilot lifecycle, and buyer-facing interpretation policy. It does not own reusable psychometric numerical estimation or model-provider orchestration. New importance/significance/actionability/evidence-style instruments need a versioned observation contract before an administrator can pilot or publish them. + +Collapsing missingness or adjudication into a numeric response corrupts the measurement channel. Collapsing Rasch into a generic one-parameter logistic label also erases the model's measurement-theoretic requirements. Conversely, choosing 3PLM or 4PLM merely because they fit better statistically over-parameterizes the instrument without a substantive asymptote mechanism or recovery evidence. + +## Decision + +1. New evaluative instruments use dichotomous observations by default. `0` means the versioned not-supported criterion is met; `1` means the versioned supported criterion is met. Missing, not-observable, abstain, invalid-evidence, and adjudication-required states remain outside the binary response. +2. The normal production model-family identifiers are `rasch`, `irt_2plm`, `irt_3plm`, and `irt_4plm`. Rasch is not an alias for generic 1PL logistic IRT. Generic `irt_1pl_logistic` is not exposed without a future superseding scientific ADR. +3. Educational measurement selects Rasch only when Rasch requirements are intended. A 3PLM is eligible only when a lower-asymptote/guessing mechanism is substantively justified. If neither is defensible, model selection fails closed. +4. Psychology/SEM-lineage dichotomous measurement defaults to 2PLM when item discrimination may vary. +5. Gambling/gaming-risk or analogous use may select 4PLM only when both lower- and upper-asymptote mechanisms are substantively justified and identifiable. Better likelihood alone is insufficient. +6. Draft and pilot instruments may preserve observations without a latent scoring model. A published instrument must bind a model family and an activation-evidence reference. Insufficient evidence preserves observations without issuing a latent score. +7. This repository's policy contract performs no numerical estimation. fast-mlsirm owns reusable psychometric kernels and recovery diagnostics; TEPP owns temporal/event/multilevel measurement semantics; contextual-orchestrator owns all LLM model/provider routing and judge orchestration. + +## Activation evidence + +Operational scoring is fail-closed. The administrator must bind evidence appropriate to the intended use, including known-truth or controlled-data recovery, bias and MAE/RMSE, interval coverage, convergence, dimensionality/local dependence, linking/anchors, DIF/invariance, and judge-facet recovery when LLM raters are used. 3PLM additionally requires lower-asymptote recovery/identification evidence. 4PLM additionally requires lower- and upper-asymptote recovery/identification and boundary-behavior evidence. + +## Consequences + +- Existing ordinal observations are not mechanically dichotomized; they retain their existing versioned contract. +- Paired-comparison/ranking outcomes remain a separate Bradley-Terry/Thurstone-style dichotomous channel. +- LLM-as-a-Judge observations remain rater/method observations with provenance, never ground truth. +- The administrator workbench can build on a stable policy vocabulary without embedding estimation code or provider logic in LineageWeave. +- A future model-family change or activation rule change requires an explicit versioned policy/ADR update rather than a silent enum or UI relabel. + +## Verification + +`tests/test_measurement_policy.py` protects binary response semantics, missing/abstain separation, Rasch/2PLM/3PLM/4PLM identifiers, domain-default rules, and fail-closed publication activation. `tests/test_ddd_architecture_fitness.py` separately rejects Rasch↔1PL shorthand in production runtime vocabulary. From edbc471e1eef53ad607f6e2e3224fd13364d9366 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:52:33 +0900 Subject: [PATCH 06/18] docs(changelog): record measurement policy contract --- CHANGELOG.d/dichotomous-measurement-policy.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.d/dichotomous-measurement-policy.md diff --git a/CHANGELOG.d/dichotomous-measurement-policy.md b/CHANGELOG.d/dichotomous-measurement-policy.md new file mode 100644 index 000000000..5648c3cc6 --- /dev/null +++ b/CHANGELOG.d/dichotomous-measurement-policy.md @@ -0,0 +1,3 @@ +## Added + +- Added a LineageWeave-owned dichotomous measurement-policy contract that keeps missing/abstain/adjudication states outside 0/1 responses, keeps Rasch distinct from generic one-parameter logistic IRT, exposes only `rasch`, `irt_2plm`, `irt_3plm`, and `irt_4plm` as normal production model families, and keeps draft/pilot observations scoreless until activation evidence supports publication. From 211555ad9fae852eb1e6913fed52ae04c7ea0583 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:54:02 +0900 Subject: [PATCH 07/18] test(measurement): reject conflicting model mechanisms --- tests/test_measurement_policy.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_measurement_policy.py b/tests/test_measurement_policy.py index 498933bdb..12da8e721 100644 --- a/tests/test_measurement_policy.py +++ b/tests/test_measurement_policy.py @@ -158,3 +158,24 @@ def test_gambling_risk_requires_both_asymptote_mechanisms_for_4plm() -> None: ) is None ) + + +def test_conflicting_model_mechanisms_fail_closed() -> None: + """Mutually incompatible model rationales never resolve by parameter precedence.""" + assert ( + default_dichotomous_model_family( + MeasurementDomain.EDUCATIONAL, + rasch_requirements_intended=True, + lower_asymptote_justified=True, + ) + is None + ) + assert ( + default_dichotomous_model_family( + MeasurementDomain.GAMBLING_GAMING_RISK, + rasch_requirements_intended=True, + lower_asymptote_justified=True, + upper_asymptote_justified=True, + ) + is None + ) From 8dd13cf6ca3dd5aa5fa92a1acfcc855bb8f60298 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:54:33 +0900 Subject: [PATCH 08/18] fix(measurement): fail closed on conflicting model mechanisms --- lineageweave/measurement_policy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index 32b825931..ae279232d 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -156,6 +156,8 @@ def default_dichotomous_model_family( """ if domain is MeasurementDomain.EDUCATIONAL: if rasch_requirements_intended: + if lower_asymptote_justified or upper_asymptote_justified: + return None return MeasurementModelFamily.RASCH if lower_asymptote_justified and not upper_asymptote_justified: return MeasurementModelFamily.IRT_3PLM @@ -167,6 +169,8 @@ def default_dichotomous_model_family( return MeasurementModelFamily.IRT_2PLM if domain is MeasurementDomain.GAMBLING_GAMING_RISK: + if rasch_requirements_intended: + return None if lower_asymptote_justified and upper_asymptote_justified: return MeasurementModelFamily.IRT_4PLM return None From 824425146d356fc7b66b409047bfe449cb0581f3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 1 Sep 2026 15:13:17 +0900 Subject: [PATCH 09/18] fix(measurement): remove heuristic model selection Signed-off-by: Codex --- CHANGELOG.d/dichotomous-measurement-policy.md | 2 +- .../0301-dichotomous-measurement-policy.md | 24 +++-- docs/ubiquitous-language.md | 2 +- lineageweave/measurement_policy.py | 46 --------- tests/test_measurement_policy.py | 94 ++++++------------- 5 files changed, 50 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.d/dichotomous-measurement-policy.md b/CHANGELOG.d/dichotomous-measurement-policy.md index 5648c3cc6..0743e9a09 100644 --- a/CHANGELOG.d/dichotomous-measurement-policy.md +++ b/CHANGELOG.d/dichotomous-measurement-policy.md @@ -1,3 +1,3 @@ ## Added -- Added a LineageWeave-owned dichotomous measurement-policy contract that keeps missing/abstain/adjudication states outside 0/1 responses, keeps Rasch distinct from generic one-parameter logistic IRT, exposes only `rasch`, `irt_2plm`, `irt_3plm`, and `irt_4plm` as normal production model families, and keeps draft/pilot observations scoreless until activation evidence supports publication. +- Added a LineageWeave-owned dichotomous measurement-policy contract that keeps missing/abstain/adjudication states outside 0/1 responses, keeps Rasch distinct from generic one-parameter logistic IRT, exposes only `rasch`, `irt_2plm`, `irt_3plm`, and `irt_4plm` as normal production model families, does not infer a family from domain labels or hand-authored flags, and keeps draft/pilot observations scoreless until activation evidence supports publication. diff --git a/docs/adr/0301-dichotomous-measurement-policy.md b/docs/adr/0301-dichotomous-measurement-policy.md index 9b269a1d2..705421dcd 100644 --- a/docs/adr/0301-dichotomous-measurement-policy.md +++ b/docs/adr/0301-dichotomous-measurement-policy.md @@ -14,11 +14,9 @@ Collapsing missingness or adjudication into a numeric response corrupts the meas 1. New evaluative instruments use dichotomous observations by default. `0` means the versioned not-supported criterion is met; `1` means the versioned supported criterion is met. Missing, not-observable, abstain, invalid-evidence, and adjudication-required states remain outside the binary response. 2. The normal production model-family identifiers are `rasch`, `irt_2plm`, `irt_3plm`, and `irt_4plm`. Rasch is not an alias for generic 1PL logistic IRT. Generic `irt_1pl_logistic` is not exposed without a future superseding scientific ADR. -3. Educational measurement selects Rasch only when Rasch requirements are intended. A 3PLM is eligible only when a lower-asymptote/guessing mechanism is substantively justified. If neither is defensible, model selection fails closed. -4. Psychology/SEM-lineage dichotomous measurement defaults to 2PLM when item discrimination may vary. -5. Gambling/gaming-risk or analogous use may select 4PLM only when both lower- and upper-asymptote mechanisms are substantively justified and identifiable. Better likelihood alone is insufficient. -6. Draft and pilot instruments may preserve observations without a latent scoring model. A published instrument must bind a model family and an activation-evidence reference. Insufficient evidence preserves observations without issuing a latent score. -7. This repository's policy contract performs no numerical estimation. fast-mlsirm owns reusable psychometric kernels and recovery diagnostics; TEPP owns temporal/event/multilevel measurement semantics; contextual-orchestrator owns all LLM model/provider routing and judge orchestration. +3. LineageWeave does not infer a model family from a product domain, item label, or hand-authored mechanism flags. Model selection remains unavailable until an administrator explicitly binds a family to evidence from the intended use and the owning psychometric runtime validates the corresponding recovery contract. +4. Draft and pilot instruments may preserve observations without a latent scoring model. A published instrument must bind a model family and an activation-evidence reference. Insufficient evidence preserves observations without issuing a latent score. +5. This repository's policy contract performs no numerical estimation. fast-mlsirm owns reusable psychometric kernels and recovery diagnostics; TEPP owns temporal/event/multilevel measurement semantics; contextual-orchestrator owns all LLM model/provider routing and judge orchestration. ## Activation evidence @@ -34,4 +32,18 @@ Operational scoring is fail-closed. The administrator must bind evidence appropr ## Verification -`tests/test_measurement_policy.py` protects binary response semantics, missing/abstain separation, Rasch/2PLM/3PLM/4PLM identifiers, domain-default rules, and fail-closed publication activation. `tests/test_ddd_architecture_fitness.py` separately rejects Rasch↔1PL shorthand in production runtime vocabulary. +`tests/test_measurement_policy.py` protects binary response semantics, missing/abstain separation, Rasch/2PLM/3PLM/4PLM identifiers, and fail-closed publication activation. `tests/test_ddd_architecture_fitness.py` separately rejects Rasch↔1PL shorthand in production runtime vocabulary. + +## References (APA 7th) + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + +Birnbaum, A. (1968). Some latent trait models and their use in inferring an +examinee's ability. In F. M. Lord & M. R. Novick, *Statistical theories of +mental test scores* (pp. 397–479). Addison-Wesley. + +Lord, F. M. (1980). *Applications of item response theory to practical +testing problems*. Lawrence Erlbaum Associates. diff --git a/docs/ubiquitous-language.md b/docs/ubiquitous-language.md index cdc136b7d..ef300ab5a 100644 --- a/docs/ubiquitous-language.md +++ b/docs/ubiquitous-language.md @@ -43,7 +43,7 @@ The governed transition from pilot to operational scoring after the instrument's A Rasch-family measurement model with its own measurement-theoretic requirements, including the intended invariance/specific-objectivity interpretation, common discrimination as part of the model, targeting, and Rasch-specific fit expectations. `rasch` is never an alias, label, or shorthand for generic one-parameter logistic IRT. **2PLM (`irt_2plm`)** -A logistic IRT family allowing item discrimination to vary. It is the default logistic IRT candidate for psychology/SEM-lineage measurement when varying discrimination is substantively allowed. +A logistic IRT family allowing item discrimination to vary. It is selected only through explicit intended-use evidence and the owning psychometric runtime's recovery contract; LineageWeave does not infer it from a domain label. **3PLM (`irt_3plm`)** A dichotomous IRT family with a lower asymptote. It is considered only when a substantive lower-asymptote/guessing mechanism is justified and identifiable; better likelihood alone is insufficient. diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index ae279232d..e88ce727a 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -24,14 +24,6 @@ class MeasurementModelFamily(StrEnum): IRT_4PLM = "irt_4plm" -class MeasurementDomain(StrEnum): - """Domain contexts that determine the default dichotomous model policy.""" - - EDUCATIONAL = "educational" - PSYCHOLOGY_SEM = "psychology_sem" - GAMBLING_GAMING_RISK = "gambling_gaming_risk" - - class InstrumentLifecycle(StrEnum): """Governed lifecycle of a versioned measurement instrument.""" @@ -138,41 +130,3 @@ def unscored( if state is DichotomousObservationState.OBSERVED: raise ValueError("observed state requires an explicit 0 or 1 response") return cls(state, None) - - -def default_dichotomous_model_family( - domain: MeasurementDomain, - *, - rasch_requirements_intended: bool = False, - lower_asymptote_justified: bool = False, - upper_asymptote_justified: bool = False, -) -> MeasurementModelFamily | None: - """Return the governed default family when its mechanism is explicit. - - ``None`` is a deliberate fail-closed result: the observations may be kept, - but LineageWeave must not invent a latent score until a scientifically - defensible model is selected and activated. This function selects policy; - it performs no numerical psychometric computation. - """ - if domain is MeasurementDomain.EDUCATIONAL: - if rasch_requirements_intended: - if lower_asymptote_justified or upper_asymptote_justified: - return None - return MeasurementModelFamily.RASCH - if lower_asymptote_justified and not upper_asymptote_justified: - return MeasurementModelFamily.IRT_3PLM - return None - - if domain is MeasurementDomain.PSYCHOLOGY_SEM: - if rasch_requirements_intended or lower_asymptote_justified or upper_asymptote_justified: - return None - return MeasurementModelFamily.IRT_2PLM - - if domain is MeasurementDomain.GAMBLING_GAMING_RISK: - if rasch_requirements_intended: - return None - if lower_asymptote_justified and upper_asymptote_justified: - return MeasurementModelFamily.IRT_4PLM - return None - - return None diff --git a/tests/test_measurement_policy.py b/tests/test_measurement_policy.py index 12da8e721..2b4cd41b3 100644 --- a/tests/test_measurement_policy.py +++ b/tests/test_measurement_policy.py @@ -10,9 +10,7 @@ DichotomousObservationState, InstrumentLifecycle, InstrumentMeasurementPolicy, - MeasurementDomain, MeasurementModelFamily, - default_dichotomous_model_family, ) @@ -71,6 +69,9 @@ def test_item_policy_names_both_sides_of_the_binary_rubric() -> None: supported_criterion="same", ) + with pytest.raises(ValueError, match="non-empty"): + DichotomousItemPolicy(" ", "v1", "not supported", "supported") + def test_pilot_instrument_may_preserve_observations_without_a_latent_model() -> None: """Pilot data are valid evidence even when no scoring model is defensible yet.""" @@ -114,68 +115,33 @@ def test_published_instrument_requires_model_and_activation_evidence() -> None: assert policy.lifecycle is InstrumentLifecycle.PUBLISHED -def test_education_requires_an_explicit_rasch_or_guessing_mechanism() -> None: - """Educational use never falls back silently to generic one-parameter IRT.""" - assert ( - default_dichotomous_model_family( - MeasurementDomain.EDUCATIONAL, - rasch_requirements_intended=True, - ) - is MeasurementModelFamily.RASCH - ) - assert ( - default_dichotomous_model_family( - MeasurementDomain.EDUCATIONAL, - lower_asymptote_justified=True, - ) - is MeasurementModelFamily.IRT_3PLM - ) - assert default_dichotomous_model_family(MeasurementDomain.EDUCATIONAL) is None - - -def test_psychology_defaults_to_2plm_when_discrimination_may_vary() -> None: - """Psychology/SEM-lineage policy uses 2PLM as the default logistic family.""" - assert ( - default_dichotomous_model_family(MeasurementDomain.PSYCHOLOGY_SEM) - is MeasurementModelFamily.IRT_2PLM - ) - - -def test_gambling_risk_requires_both_asymptote_mechanisms_for_4plm() -> None: - """4PLM is selected only when lower and upper asymptotes are both justified.""" - assert ( - default_dichotomous_model_family( - MeasurementDomain.GAMBLING_GAMING_RISK, - lower_asymptote_justified=True, - upper_asymptote_justified=True, - ) - is MeasurementModelFamily.IRT_4PLM - ) - assert ( - default_dichotomous_model_family( - MeasurementDomain.GAMBLING_GAMING_RISK, - lower_asymptote_justified=True, +@pytest.mark.parametrize( + ("instrument_id", "revision", "activation_evidence_ref", "message"), + [ + (" ", 1, None, "instrument_id"), + ("instrument", 0, None, "positive integer"), + ("instrument", True, None, "positive integer"), + ("instrument", 1, " ", "non-empty"), + ], +) +def test_instrument_identity_revision_and_evidence_are_bounded( + instrument_id: str, + revision: int, + activation_evidence_ref: str | None, + message: str, +) -> None: + """Invalid identity, revision, and evidence values fail before activation.""" + with pytest.raises(ValueError, match=message): + InstrumentMeasurementPolicy( + instrument_id=instrument_id, + revision=revision, + lifecycle=InstrumentLifecycle.PILOT, + model_family=None, + activation_evidence_ref=activation_evidence_ref, ) - is None - ) -def test_conflicting_model_mechanisms_fail_closed() -> None: - """Mutually incompatible model rationales never resolve by parameter precedence.""" - assert ( - default_dichotomous_model_family( - MeasurementDomain.EDUCATIONAL, - rasch_requirements_intended=True, - lower_asymptote_justified=True, - ) - is None - ) - assert ( - default_dichotomous_model_family( - MeasurementDomain.GAMBLING_GAMING_RISK, - rasch_requirements_intended=True, - lower_asymptote_justified=True, - upper_asymptote_justified=True, - ) - is None - ) +def test_unscored_state_rejects_a_binary_payload_at_the_constructor_boundary() -> None: + """Direct construction cannot bypass the unscored-state invariant.""" + with pytest.raises(ValueError, match="must not carry"): + DichotomousObservation(DichotomousObservationState.MISSING, 0) From 97a39d365e673fe1c2833ccd076db6eb68d42c1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:59:57 +0900 Subject: [PATCH 10/18] test(measurement): reject unparsed policy enum strings --- .../test_measurement_policy_runtime_types.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/test_measurement_policy_runtime_types.py diff --git a/tests/test_measurement_policy_runtime_types.py b/tests/test_measurement_policy_runtime_types.py new file mode 100644 index 000000000..8f43a96dd --- /dev/null +++ b/tests/test_measurement_policy_runtime_types.py @@ -0,0 +1,54 @@ +"""Fail-closed runtime type checks for the measurement policy boundary.""" + +from __future__ import annotations + +import pytest + +from lineageweave.measurement_policy import ( + DichotomousObservation, + InstrumentLifecycle, + InstrumentMeasurementPolicy, + MeasurementModelFamily, +) + + +def test_string_lifecycle_cannot_bypass_published_activation_requirements() -> None: + """A deserialized-looking string must not evade the published-state invariant.""" + with pytest.raises(TypeError, match="lifecycle"): + InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle="published", # type: ignore[arg-type] + model_family=None, + activation_evidence_ref=None, + ) + + +def test_string_model_family_is_not_accepted_as_a_governed_family() -> None: + """The domain object accepts only the versioned enum, not an unvalidated string.""" + with pytest.raises(TypeError, match="model_family"): + InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PUBLISHED, + model_family="irt_2plm", # type: ignore[arg-type] + activation_evidence_ref="evidence://pilot/2026-09", + ) + + +def test_string_observation_state_cannot_enter_the_binary_policy_domain() -> None: + """Adapters must parse the governed state before constructing a domain observation.""" + with pytest.raises(TypeError, match="state"): + DichotomousObservation("missing", None) # type: ignore[arg-type] + + +def test_governed_enum_instances_still_construct_normally() -> None: + """Runtime hardening must preserve the supported typed contract.""" + policy = InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PUBLISHED, + model_family=MeasurementModelFamily.IRT_2PLM, + activation_evidence_ref="evidence://pilot/2026-09", + ) + assert policy.model_family is MeasurementModelFamily.IRT_2PLM From cca6d838e8c377070bec1c71b7aaec8f2762424f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:01:00 +0900 Subject: [PATCH 11/18] fix(measurement): enforce governed enum types at runtime --- lineageweave/measurement_policy.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index e88ce727a..21cf449c1 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -73,7 +73,9 @@ class InstrumentMeasurementPolicy: Draft and pilot instruments may preserve observations without a latent model. A published instrument must bind both a governed model family and an activation-evidence reference; otherwise operational latent scoring remains - unavailable. + unavailable. Adapters must parse lifecycle and model-family strings into the + governed enums before constructing this domain object; raw strings cannot + bypass lifecycle-specific activation rules. """ instrument_id: str @@ -83,6 +85,12 @@ class InstrumentMeasurementPolicy: activation_evidence_ref: str | None def __post_init__(self) -> None: + if not isinstance(self.lifecycle, InstrumentLifecycle): + raise TypeError("lifecycle must be an InstrumentLifecycle") + if self.model_family is not None and not isinstance( + self.model_family, MeasurementModelFamily + ): + raise TypeError("model_family must be a MeasurementModelFamily when supplied") if not self.instrument_id.strip(): raise ValueError("instrument_id must be non-empty") if type(self.revision) is not int or self.revision < 1: @@ -103,12 +111,16 @@ class DichotomousObservation: ``response=0`` means the versioned support criterion was not satisfied and ``response=1`` means it was satisfied. A response is never used to encode missingness, abstention, invalid evidence, or an unresolved adjudication. + Adapters must parse the observation-state enum before construction so a raw + string cannot silently enter the governed binary-response domain. """ state: DichotomousObservationState response: int | None def __post_init__(self) -> None: + if not isinstance(self.state, DichotomousObservationState): + raise TypeError("state must be a DichotomousObservationState") if self.state is DichotomousObservationState.OBSERVED: if type(self.response) is not int or self.response not in (0, 1): raise ValueError("observed dichotomous response must be integer 0 or 1") From 636a8dfbe070a612a703e570a1a3342ac3fcea4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:58:59 +0900 Subject: [PATCH 12/18] test(measurement): reject unparsed string-field values --- .../test_measurement_policy_runtime_types.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_measurement_policy_runtime_types.py b/tests/test_measurement_policy_runtime_types.py index 8f43a96dd..0b08c4fcb 100644 --- a/tests/test_measurement_policy_runtime_types.py +++ b/tests/test_measurement_policy_runtime_types.py @@ -5,6 +5,7 @@ import pytest from lineageweave.measurement_policy import ( + DichotomousItemPolicy, DichotomousObservation, InstrumentLifecycle, InstrumentMeasurementPolicy, @@ -42,6 +43,41 @@ def test_string_observation_state_cannot_enter_the_binary_policy_domain() -> Non DichotomousObservation("missing", None) # type: ignore[arg-type] +def test_item_policy_rejects_non_string_transport_fields_explicitly() -> None: + """Malformed transport values must fail at the item-policy boundary, not via ``.strip``.""" + with pytest.raises(TypeError, match="item policy fields must be strings"): + DichotomousItemPolicy( + item_id=17, # type: ignore[arg-type] + rubric_version="v1", + not_supported_criterion="No supporting evidence", + supported_criterion="Supporting evidence present", + ) + + +def test_instrument_policy_rejects_non_string_identity_explicitly() -> None: + """Instrument identity is a governed string and must not rely on incidental ``.strip`` errors.""" + with pytest.raises(TypeError, match="instrument_id"): + InstrumentMeasurementPolicy( + instrument_id=17, # type: ignore[arg-type] + revision=1, + lifecycle=InstrumentLifecycle.DRAFT, + model_family=None, + activation_evidence_ref=None, + ) + + +def test_instrument_policy_rejects_non_string_activation_reference_explicitly() -> None: + """Activation evidence references must be parsed to text before entering product policy.""" + with pytest.raises(TypeError, match="activation_evidence_ref"): + InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=1, + lifecycle=InstrumentLifecycle.PUBLISHED, + model_family=MeasurementModelFamily.IRT_2PLM, + activation_evidence_ref=17, # type: ignore[arg-type] + ) + + def test_governed_enum_instances_still_construct_normally() -> None: """Runtime hardening must preserve the supported typed contract.""" policy = InstrumentMeasurementPolicy( From b9ab660f3a5c371265401f4e3b6db1534a2d03f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:59:30 +0900 Subject: [PATCH 13/18] fix(measurement): enforce string-valued policy boundary --- lineageweave/measurement_policy.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index 21cf449c1..d70bb15d5 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -60,6 +60,8 @@ def __post_init__(self) -> None: self.not_supported_criterion, self.supported_criterion, ) + if any(not isinstance(value, str) for value in values): + raise TypeError("dichotomous item policy fields must be strings") if any(not value.strip() for value in values): raise ValueError("dichotomous item policy fields must be non-empty") if self.not_supported_criterion.strip() == self.supported_criterion.strip(): @@ -75,7 +77,9 @@ class InstrumentMeasurementPolicy: activation-evidence reference; otherwise operational latent scoring remains unavailable. Adapters must parse lifecycle and model-family strings into the governed enums before constructing this domain object; raw strings cannot - bypass lifecycle-specific activation rules. + bypass lifecycle-specific activation rules. Identity and evidence references + likewise must be parsed to text before construction instead of relying on + incidental string-method failures. """ instrument_id: str @@ -91,12 +95,17 @@ def __post_init__(self) -> None: self.model_family, MeasurementModelFamily ): raise TypeError("model_family must be a MeasurementModelFamily when supplied") + if not isinstance(self.instrument_id, str): + raise TypeError("instrument_id must be a string") if not self.instrument_id.strip(): raise ValueError("instrument_id must be non-empty") if type(self.revision) is not int or self.revision < 1: raise ValueError("instrument revision must be a positive integer") - if self.activation_evidence_ref is not None and not self.activation_evidence_ref.strip(): - raise ValueError("activation evidence reference must be non-empty when supplied") + if self.activation_evidence_ref is not None: + if not isinstance(self.activation_evidence_ref, str): + raise TypeError("activation_evidence_ref must be a string when supplied") + if not self.activation_evidence_ref.strip(): + raise ValueError("activation evidence reference must be non-empty when supplied") if self.lifecycle is InstrumentLifecycle.PUBLISHED: if self.model_family is None: raise ValueError("published instrument requires a measurement model family") From 49a82aa18be438311c0c1f3abc7eaf151f073b93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:25:21 +0900 Subject: [PATCH 14/18] test(measurement): distinguish malformed numeric transport types --- .../test_measurement_policy_runtime_types.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_measurement_policy_runtime_types.py b/tests/test_measurement_policy_runtime_types.py index 0b08c4fcb..0816843b6 100644 --- a/tests/test_measurement_policy_runtime_types.py +++ b/tests/test_measurement_policy_runtime_types.py @@ -7,6 +7,7 @@ from lineageweave.measurement_policy import ( DichotomousItemPolicy, DichotomousObservation, + DichotomousObservationState, InstrumentLifecycle, InstrumentMeasurementPolicy, MeasurementModelFamily, @@ -78,6 +79,30 @@ def test_instrument_policy_rejects_non_string_activation_reference_explicitly() ) +def test_instrument_revision_rejects_boolean_transport_value_as_wrong_type() -> None: + """JSON booleans are Python integers; they must not become instrument revisions.""" + with pytest.raises(TypeError, match="revision"): + InstrumentMeasurementPolicy( + instrument_id="importance-evidence", + revision=True, # type: ignore[arg-type] + lifecycle=InstrumentLifecycle.DRAFT, + model_family=None, + activation_evidence_ref=None, + ) + + +def test_observed_response_rejects_boolean_transport_value_as_wrong_type() -> None: + """A JSON boolean must not be accepted or classified as an ordinary invalid 0/1 score.""" + with pytest.raises(TypeError, match="response"): + DichotomousObservation(DichotomousObservationState.OBSERVED, True) # type: ignore[arg-type] + + +def test_unscored_response_rejects_non_integer_transport_value_as_wrong_type() -> None: + """Malformed non-null response payloads fail at the type boundary before state semantics.""" + with pytest.raises(TypeError, match="response"): + DichotomousObservation(DichotomousObservationState.MISSING, "0") # type: ignore[arg-type] + + def test_governed_enum_instances_still_construct_normally() -> None: """Runtime hardening must preserve the supported typed contract.""" policy = InstrumentMeasurementPolicy( From ec2ab254b85a9ed4bef50c1de8214bbc56e201bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:25:42 +0900 Subject: [PATCH 15/18] fix(measurement): separate transport type failures from policy values --- lineageweave/measurement_policy.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index d70bb15d5..33e676933 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -99,7 +99,9 @@ def __post_init__(self) -> None: raise TypeError("instrument_id must be a string") if not self.instrument_id.strip(): raise ValueError("instrument_id must be non-empty") - if type(self.revision) is not int or self.revision < 1: + if type(self.revision) is not int: + raise TypeError("instrument revision must be an integer") + if self.revision < 1: raise ValueError("instrument revision must be a positive integer") if self.activation_evidence_ref is not None: if not isinstance(self.activation_evidence_ref, str): @@ -130,8 +132,10 @@ class DichotomousObservation: def __post_init__(self) -> None: if not isinstance(self.state, DichotomousObservationState): raise TypeError("state must be a DichotomousObservationState") + if self.response is not None and type(self.response) is not int: + raise TypeError("response must be an integer or None") if self.state is DichotomousObservationState.OBSERVED: - if type(self.response) is not int or self.response not in (0, 1): + if self.response not in (0, 1): raise ValueError("observed dichotomous response must be integer 0 or 1") return if self.response is not None: From bde68937a4f8b30a5a2b9f57475737ef568d489d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:47:53 +0900 Subject: [PATCH 16/18] test(measurement): align value checks with transport type boundary --- tests/test_measurement_policy.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_measurement_policy.py b/tests/test_measurement_policy.py index 2b4cd41b3..1feea99fd 100644 --- a/tests/test_measurement_policy.py +++ b/tests/test_measurement_policy.py @@ -29,9 +29,9 @@ def test_observed_dichotomous_response_accepts_only_zero_or_one() -> None: assert DichotomousObservation.observed(0).response == 0 assert DichotomousObservation.observed(1).response == 1 - for invalid in (-1, 2, True, False): + for invalid in (-1, 2): with pytest.raises(ValueError, match="0 or 1"): - DichotomousObservation.observed(invalid) # type: ignore[arg-type] + DichotomousObservation.observed(invalid) def test_nonobserved_states_never_carry_a_binary_response() -> None: @@ -120,7 +120,6 @@ def test_published_instrument_requires_model_and_activation_evidence() -> None: [ (" ", 1, None, "instrument_id"), ("instrument", 0, None, "positive integer"), - ("instrument", True, None, "positive integer"), ("instrument", 1, " ", "non-empty"), ], ) From df0655750a9e66974d94ba8add6784f421977f2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:48:08 +0900 Subject: [PATCH 17/18] test(measurement): reject whitespace aliases in canonical refs --- .../test_measurement_policy_canonical_text.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_measurement_policy_canonical_text.py diff --git a/tests/test_measurement_policy_canonical_text.py b/tests/test_measurement_policy_canonical_text.py new file mode 100644 index 000000000..9c5970f17 --- /dev/null +++ b/tests/test_measurement_policy_canonical_text.py @@ -0,0 +1,50 @@ +"""Canonical-text boundaries for LineageWeave-owned measurement policy.""" + +from __future__ import annotations + +import pytest + +from lineageweave.measurement_policy import ( + DichotomousItemPolicy, + InstrumentLifecycle, + InstrumentMeasurementPolicy, +) + + +@pytest.mark.parametrize("field_name", ["item_id", "rubric_version"]) +def test_item_policy_rejects_surrounding_whitespace_in_identity_fields( + field_name: str, +) -> None: + """Opaque item and rubric identities must not acquire whitespace aliases.""" + values = { + "item_id": "item-1", + "rubric_version": "2026-09-01", + "not_supported_criterion": "Evidence does not support the criterion.", + "supported_criterion": "Evidence supports the criterion.", + } + values[field_name] = f" {values[field_name]} " + + with pytest.raises(ValueError, match="surrounding whitespace"): + DichotomousItemPolicy(**values) + + +@pytest.mark.parametrize( + ("instrument_id", "activation_evidence_ref"), + [ + (" instrument-1 ", None), + ("instrument-1", " evidence://pilot/2026-09 "), + ], +) +def test_instrument_policy_rejects_surrounding_whitespace_in_canonical_refs( + instrument_id: str, + activation_evidence_ref: str | None, +) -> None: + """Instrument and evidence references fail closed instead of forming aliases.""" + with pytest.raises(ValueError, match="surrounding whitespace"): + InstrumentMeasurementPolicy( + instrument_id=instrument_id, + revision=1, + lifecycle=InstrumentLifecycle.PILOT, + model_family=None, + activation_evidence_ref=activation_evidence_ref, + ) From b6efdf9ee3ba2690c37e931e2c127cc8c221e491 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:48:34 +0900 Subject: [PATCH 18/18] fix(measurement): reject whitespace aliases in canonical refs --- lineageweave/measurement_policy.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lineageweave/measurement_policy.py b/lineageweave/measurement_policy.py index 33e676933..6c722a4b6 100644 --- a/lineageweave/measurement_policy.py +++ b/lineageweave/measurement_policy.py @@ -64,6 +64,12 @@ def __post_init__(self) -> None: raise TypeError("dichotomous item policy fields must be strings") if any(not value.strip() for value in values): raise ValueError("dichotomous item policy fields must be non-empty") + for field_name, value in ( + ("item_id", self.item_id), + ("rubric_version", self.rubric_version), + ): + if value != value.strip(): + raise ValueError(f"{field_name} must not contain surrounding whitespace") if self.not_supported_criterion.strip() == self.supported_criterion.strip(): raise ValueError("0 and 1 rubric criteria must be distinct") @@ -99,6 +105,8 @@ def __post_init__(self) -> None: raise TypeError("instrument_id must be a string") if not self.instrument_id.strip(): raise ValueError("instrument_id must be non-empty") + if self.instrument_id != self.instrument_id.strip(): + raise ValueError("instrument_id must not contain surrounding whitespace") if type(self.revision) is not int: raise TypeError("instrument revision must be an integer") if self.revision < 1: @@ -108,6 +116,10 @@ def __post_init__(self) -> None: raise TypeError("activation_evidence_ref must be a string when supplied") if not self.activation_evidence_ref.strip(): raise ValueError("activation evidence reference must be non-empty when supplied") + if self.activation_evidence_ref != self.activation_evidence_ref.strip(): + raise ValueError( + "activation_evidence_ref must not contain surrounding whitespace" + ) if self.lifecycle is InstrumentLifecycle.PUBLISHED: if self.model_family is None: raise ValueError("published instrument requires a measurement model family")