diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index 5bc8779b..9808b87c 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -385,6 +385,11 @@ _BY_RELATION_TYPE: dict[str, ConstructionFamily] = { for family in _CATALOG.values() } +_PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset({ + "proportional_change.decrease_to_fraction", + "partition.percent_partition", +}) + # --------------------------------------------------------------------------- # Public accessors @@ -456,10 +461,9 @@ def make_proposal( ) -> ConstructionProposal: """Map assessment evidence onto a proposal for legacy catalog paths. - Proportional decrease no longer uses this assessment-backed adapter; it - enters through :func:`propose_construction`. The adapter remains for - catalog families that have not yet received an authorized proposal-first - migration. + Migrated proposal-first families must enter through + :func:`propose_construction`. This adapter remains only for explicitly + unmigrated catalog paths that still synthesize proposals from assessments. Args: family_id: Catalog family identifier. @@ -478,7 +482,12 @@ def make_proposal( Raises: KeyError: If *family_id* is not registered in the catalog. + ValueError: If *family_id* has already migrated to the proposal-first seam. """ + if family_id in _PROPOSAL_FIRST_FAMILIES: + raise ValueError( + f"{family_id} is proposal-first; use propose_construction before assessment" + ) proposal = propose_construction(family_id, evidence_spans) if assessment_runnable: diff --git a/tests/test_construction_affordances.py b/tests/test_construction_affordances.py index e070b160..b7532862 100644 --- a/tests/test_construction_affordances.py +++ b/tests/test_construction_affordances.py @@ -1,12 +1,15 @@ +from __future__ import annotations + import pytest + from generate.construction_affordances import ( + ConstructionProposal, all_diagnostic_families, - lookup_family, lookup_by_organ, lookup_by_relation_type, + lookup_family, make_proposal, propose_construction, - ConstructionProposal, ) from generate.kernel_facts import SourceSpan @@ -22,7 +25,6 @@ def test_catalog_entries_are_diagnostic_only_and_serving_forbidden() -> None: def test_catalog_ordering_is_deterministic() -> None: families = all_diagnostic_families() ids = [f.family_id for f in families] - # Check that they are sorted assert ids == sorted(ids) assert ids == [ "partition.percent_partition", @@ -31,114 +33,94 @@ def test_catalog_ordering_is_deterministic() -> None: def test_lookups_correctness() -> None: - # 1. lookup_family - f1 = lookup_family("proportional_change.decrease_to_fraction") - assert f1 is not None - assert f1.family_id == "proportional_change.decrease_to_fraction" + fraction_family = lookup_family("proportional_change.decrease_to_fraction") + assert fraction_family is not None + assert lookup_by_organ("fraction_decrease") is fraction_family + assert lookup_by_relation_type("decrease_to_fraction") is fraction_family - f2 = lookup_family("partition.percent_partition") - assert f2 is not None - assert f2.family_id == "partition.percent_partition" + partition_family = lookup_family("partition.percent_partition") + assert partition_family is not None + assert lookup_by_organ("percent_partition") is partition_family + assert lookup_by_relation_type("percent_of") is partition_family assert lookup_family("invalid_family_id") is None - - # 2. lookup_by_organ - assert lookup_by_organ("fraction_decrease") is f1 - assert lookup_by_organ("percent_partition") is f2 assert lookup_by_organ("invalid_organ") is None - - # 3. lookup_by_relation_type - assert lookup_by_relation_type("decrease_to_fraction") is f1 - assert lookup_by_relation_type("percent_of") is f2 assert lookup_by_relation_type("invalid_relation") is None -def test_make_proposal_validation_and_status() -> None: - span = SourceSpan("test text", 0, 9) +@pytest.mark.parametrize( + ("family_id", "relation_type", "candidate_organ", "required_roles"), + ( + ( + "proportional_change.decrease_to_fraction", + "decrease_to_fraction", + "fraction_decrease", + {"base_quantity", "scale", "state_entity", "transition"}, + ), + ( + "partition.percent_partition", + "percent_of", + "percent_partition", + {"whole", "part", "scale"}, + ), + ), +) +def test_propose_construction_is_preassessment_and_catalog_backed( + family_id: str, + relation_type: str, + candidate_organ: str, + required_roles: set[str], +) -> None: + span = SourceSpan("surface cue", 0, 11) - # 1. Closed status (runnable = True) - prop_closed = make_proposal( - family_id="proportional_change.decrease_to_fraction", - evidence_spans=(span,), - assessment_runnable=True, - missing_roles=(), - active_hazards=(), - ) - assert prop_closed.family_id == "proportional_change.decrease_to_fraction" - assert prop_closed.relation_type == "decrease_to_fraction" - assert prop_closed.candidate_organ == "fraction_decrease" - assert prop_closed.evidence_spans == (span,) - assert prop_closed.status == "closed" - assert prop_closed.missing_roles == () - assert prop_closed.active_hazards == () - - # 2. Refused status (active hazards present) - prop_refused = make_proposal( - family_id="proportional_change.decrease_to_fraction", - evidence_spans=(span,), - assessment_runnable=False, - missing_roles=(), - active_hazards=("unbound_base_quantity",), - ) - assert prop_refused.status == "refused" - assert prop_refused.active_hazards == ("unbound_base_quantity",) - - # 3. Partial status (missing roles present, no active hazards) - prop_partial = make_proposal( - family_id="proportional_change.decrease_to_fraction", - evidence_spans=(span,), - assessment_runnable=False, - missing_roles=("base_quantity_unbound",), - active_hazards=(), - ) - assert prop_partial.status == "partial" - assert prop_partial.missing_roles == ("base_quantity_unbound",) - - # 4. Proposed status (not runnable, no missing roles, no active hazards) - prop_proposed = make_proposal( - family_id="proportional_change.decrease_to_fraction", - evidence_spans=(span,), - assessment_runnable=False, - missing_roles=(), - active_hazards=(), - ) - assert prop_proposed.status == "proposed" - - # 5. Invalid family_id raises KeyError - with pytest.raises(KeyError): - make_proposal( - family_id="invalid_family_id", - evidence_spans=(span,), - assessment_runnable=False, - missing_roles=(), - active_hazards=(), - ) - - -def test_propose_construction_is_preassessment_and_carries_catalog_obligations() -> None: - span = SourceSpan("decrease to 3/4 of", 0, 18) - - proposal = propose_construction( - "proportional_change.decrease_to_fraction", - (span,), - ) + proposal = propose_construction(family_id, (span,)) + assert proposal.family_id == family_id + assert proposal.relation_type == relation_type + assert proposal.candidate_organ == candidate_organ + assert proposal.evidence_spans == (span,) assert proposal.status == "proposed" assert proposal.missing_roles == () assert proposal.active_hazards == () assert proposal.diagnostic_only is True assert proposal.serving_allowed is False - assert {role.role for role in proposal.role_obligations if role.required} == { - "base_quantity", - "scale", - "state_entity", - "transition", - } + assert {role.role for role in proposal.role_obligations if role.required} == required_roles + + +@pytest.mark.parametrize( + "family_id", + ( + "proportional_change.decrease_to_fraction", + "partition.percent_partition", + ), +) +def test_make_proposal_rejects_migrated_proposal_first_families( + family_id: str, +) -> None: + span = SourceSpan("surface cue", 0, 11) + + with pytest.raises( + ValueError, + match=rf"{family_id} is proposal-first; use propose_construction before assessment", + ): + make_proposal( + family_id=family_id, + evidence_spans=(span,), + assessment_runnable=True, + missing_roles=(), + active_hazards=(), + ) + + +def test_make_proposal_still_rejects_unknown_family_ids() -> None: + span = SourceSpan("surface cue", 0, 11) + + with pytest.raises(KeyError): + propose_construction("invalid_family_id", (span,)) def test_construction_proposal_status_validation() -> None: span = SourceSpan("test text", 0, 9) - # Valid status doesn't raise ConstructionProposal( family_id="proportional_change.decrease_to_fraction", relation_type="decrease_to_fraction", @@ -149,7 +131,6 @@ def test_construction_proposal_status_validation() -> None: active_hazards=(), ) - # Invalid status raises ValueError with pytest.raises(ValueError, match="status must be one of"): ConstructionProposal( family_id="proportional_change.decrease_to_fraction",