diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index f132ddb9..1614d855 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -458,11 +458,80 @@ _QUANTITY_ENTITY_FAMILY = ConstructionFamily( serving_allowed=False, ) +_UNARY_DELTA_FAMILY = ConstructionFamily( + family_id="state_change.unary_delta", + display_name="Unary gained/lost delta", + signature=ConstructionSignature( + relation_type="unary_delta", + candidate_organ="unary_delta", + required_roles=( + RoleObligation( + "action_cue", + required=True, + description="Exact local gained/lost cue span from the original text.", + ), + RoleObligation( + "delta_quantity", + required=True, + description="Exactly one grounded scalar quantity attached to the event.", + ), + RoleObligation( + "changed_object", + required=True, + description="Exactly one grounded local object whose quantity changed.", + ), + RoleObligation( + "direction", + required=True, + description="Increase for gained and decrease for lost.", + ), + ), + optional_roles=(), + ), + hazards=( + ConstructionHazard( + hazard_category="quantity_entity_nonlocal", + blocking=True, + description=( + "Pronoun repair or cross-sentence binding would be required to " + "decide the changed object." + ), + ), + ConstructionHazard( + hazard_category="percent_change_vs_percent_of", + blocking=True, + description=( + "Percent/rate/comparison phrasing would widen this slice beyond " + "a local unary gained/lost event." + ), + ), + ), + target_semantics=( + "authority:diagnostic_only", + "cue_inventory:gained|lost", + "locality:single_clause", + ), + contract_labels=( + "unary_delta_proposal_required", + "unary_delta_relation_ambiguous", + "action_cue_unbound", + "delta_quantity_unbound", + "changed_object_unbound", + "direction_unbound", + "quantity_kind_unresolved", + "provenance_span_inexact", + "quantity_entity_nonlocal", + ), + diagnostic_only=True, + serving_allowed=False, +) + # The catalog. Keys are family_id strings. Sorted for deterministic iteration. _CATALOG: dict[str, ConstructionFamily] = { _DECREASE_TO_FRACTION_FAMILY.family_id: _DECREASE_TO_FRACTION_FAMILY, _PERCENT_PARTITION_FAMILY.family_id: _PERCENT_PARTITION_FAMILY, _QUANTITY_ENTITY_FAMILY.family_id: _QUANTITY_ENTITY_FAMILY, + _UNARY_DELTA_FAMILY.family_id: _UNARY_DELTA_FAMILY, } # Secondary indices for O(1) lookup by organ or relation type. @@ -479,6 +548,7 @@ _PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset({ "binding.quantity_entity", "proportional_change.decrease_to_fraction", "partition.percent_partition", + "state_change.unary_delta", }) diff --git a/generate/foundational_families.py b/generate/foundational_families.py index 5dbb2384..30a445da 100644 --- a/generate/foundational_families.py +++ b/generate/foundational_families.py @@ -6,9 +6,9 @@ documents, recognize constructions, assess ``ProblemFrame`` contracts, or route proposals. Authorization fields mirror reviewed implementation boundaries; they never authorize serving. -The current registry deliberately contains only the two specifications approved -by the initial family-spec gate. General substrate/CGA retrieval for these -families is not live. +The current registry deliberately contains only the reviewed foundational +families approved by the family-spec gate. General substrate/CGA retrieval for +these families is not live. """ from __future__ import annotations @@ -249,7 +249,97 @@ _STATE_CHANGE = FoundationalFamilySpec( ) -_FAMILIES = (_QUANTITY_ENTITY_BINDING, _STATE_CHANGE) +_UNARY_DELTA = FoundationalFamilySpec( + family_id="state_change.unary_delta", + display_name="Unary Delta State Change", + status="Implemented (diagnostic-only; non-serving)", + related_adrs=("ADR-0223", "ADR-0224"), + domains=( + "arithmetic_quantitative", + "physical_science", + "life_science", + "reading_comprehension", + ), + summary=( + "Represents one exact local gained/lost event as a unary quantity " + "delta over a changed object, without asserting owner, before-state, " + "after-state, or arithmetic closure." + ), + surface_chunk_patterns=( + " gained ", + " lost ", + ), + semantic_neighborhood=( + "inventory_change", + "resource_delta", + "count_change", + "local_event_delta", + ), + construction_signatures=("unary_delta",), + required_roles=("action_cue", "delta_quantity", "changed_object", "direction"), + optional_roles=("subject_surface",), + hazards_confusers=( + "PF-EN-002 quantity_entity_unbound", + "PF-EN-005 role_alias_collision", + "PF-HZ-003 hazard_ignored_by_contract", + "PF-TG-004 target_direction_unknown", + ), + frame_representation=( + "ConstructionProposal(status='proposed', family_id='state_change.unary_delta')", + "BoundRelation(relation_type='unary_delta') with action_cue, delta_quantity, changed_object, and direction roles", + "Exact SourceSpan evidence for cue, quantity, and object", + "ContractAssessment(candidate_organ='unary_delta') as the sole runnable/refused authority", + ), + contract_readiness_criteria=( + "Exactly one local gained/lost cue is grounded from the original text.", + "Exactly one grounded scalar quantity and one grounded changed object are bound.", + "Quantity/object grounding composes with exact local quantity_entity evidence.", + "Direction is increase for gained and decrease for lost.", + "No transfer, containment, comparison, rate, percent, passive, modal, negated, or cross-sentence reasoning is required.", + ), + verification_style=( + "Cue substitution between gained and lost must flip direction without widening other roles.", + "Synthetic or widened cue/quantity/object spans must refuse rather than normalize.", + ), + refusal_conditions=( + "Cue, quantity, or changed object is missing or ambiguous.", + "Object grounding would require pronoun repair, cross-sentence binding, or legacy semantic-state logic.", + ), + cross_domain_evidence=( + FoundationalDomainEvidence( + domain="physical_science", + example="The jar lost 2 cookies.", + expected_roles=("action_cue", "delta_quantity", "changed_object", "direction"), + ), + FoundationalDomainEvidence( + domain="life_science", + example="The sapling gained 3 leaves.", + expected_roles=("action_cue", "delta_quantity", "changed_object", "direction"), + ), + FoundationalDomainEvidence( + domain="reading_comprehension", + example="Ana gained 3 marbles.", + expected_roles=("action_cue", "delta_quantity", "changed_object", "direction"), + ), + ), + current_state=( + "Implemented only as a bounded diagnostic proposal-first gained/lost " + "relation. No transfer semantics, owner assertion, state ledger, or " + "serving adapter is authorized." + ), + target_state=( + "Proposal-first unary-delta relation with exact cue/quantity/object grounding, " + "typed refusal, and cross-domain evidence." + ), + serving_status="Diagnostic-only implementation / not serving.", + serving_allowed=False, + implementation_authorized=True, + primary_relation_type="unary_delta", + future_adapter="unary_delta_adapter", +) + + +_FAMILIES = (_QUANTITY_ENTITY_BINDING, _STATE_CHANGE, _UNARY_DELTA) _BY_ID = MappingProxyType({family.family_id: family for family in _FAMILIES}) _BY_RELATION_TYPE = MappingProxyType( {family.primary_relation_type: family for family in _FAMILIES} diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index 39672abe..79c0a169 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -334,6 +334,12 @@ _DECREASE_DELTA_QUESTION_RE = re.compile( r"\bwhat\s+will\s+the\s+(?P[A-Za-z][A-Za-z'-]*)\s+decrease\s+by\??", re.IGNORECASE, ) +_UNARY_DELTA_RE = re.compile( + r"\b(?P(?:[A-Z][A-Za-z'-]*|[Tt]he\s+[A-Za-z][A-Za-z'-]*))\s+" + r"(?Pgained|lost)\s+" + r"(?P\d+(?:\.\d+)?)\s+" + r"(?P[A-Za-z][A-Za-z'-]*)\b" +) _ACTOR_VERB_RE = re.compile( r"\b(?P[A-Z][A-Za-z'-]*)\s+" r"(?:gave|gives|give|received|receives|spent|spends|ate|eats|bought|buys|sold|sells)\b" @@ -416,6 +422,17 @@ def _has_list_or_enumeration_suffix(text: str, end: int) -> bool: return tail.startswith((",", ";", "and ", "or ")) +def _spans_are_local( + problem_text: str, + first: SourceSpan, + second: SourceSpan, +) -> bool: + left, right = sorted((first, second), key=lambda span: span.start) + if left.end > right.start: + return False + return not any(marker in problem_text[left.end:right.start] for marker in ".!?") + + def _quantity_entity_proposals( text: str, quantities: tuple[GroundedScalar, ...], @@ -462,6 +479,42 @@ def _quantity_entity_proposals( return (propose_construction("binding.quantity_entity", (evidence,)),) +def _unary_delta_proposals( + text: str, +) -> tuple[ConstructionProposal, ...]: + """Propose the narrow gained/lost unary-delta slice from exact local cues.""" + if any( + surface_in_text(surface, text) + for surface in _QUANTITY_ENTITY_CONFUSER_SURFACES + ): + return () + + matches = tuple(_UNARY_DELTA_RE.finditer(text)) + if len(matches) != 1: + return () + match = matches[0] + if match.group("subject").lower() in _QUANTITY_ENTITY_PRONOUNS: + return () + if _has_list_or_enumeration_suffix(text, match.end("object")): + return () + sentence_ends = tuple( + index + for marker in ".!?" + if (index := text.find(marker, match.end("object"))) != -1 + ) + sentence_end = min(sentence_ends, default=len(text)) + trailing = text[match.end("object"):sentence_end].strip() + if trailing: + return () + + evidence = SourceSpan( + text[match.start("cue"):match.end("cue")], + match.start("cue"), + match.end("cue"), + ) + return (propose_construction("state_change.unary_delta", (evidence,)),) + + def _extract_mentions( text: str, quantities: tuple[GroundedScalar, ...], @@ -560,6 +613,7 @@ def _extract_bindings( def _quantity_kind_dispositions( + text: str, mentions: tuple[GroundedMention, ...], bindings: tuple[MentionBinding, ...], proposals: tuple[ConstructionProposal, ...], @@ -571,9 +625,15 @@ def _quantity_kind_dispositions( for proposal in proposals if proposal.family_id == "binding.quantity_entity" ) - if len(quantity_entity_proposals) != 1: + unary_delta_proposals = tuple( + proposal + for proposal in proposals + if proposal.family_id == "state_change.unary_delta" + ) + if len(quantity_entity_proposals) + len(unary_delta_proposals) != 1: return () - proposal = quantity_entity_proposals[0] + quantity_entity_proposal = quantity_entity_proposals[0] if quantity_entity_proposals else None + unary_delta_proposal = unary_delta_proposals[0] if unary_delta_proposals else None mentions_by_id = {mention.mention_id: mention for mention in mentions} unit_bindings: dict[str, list[MentionBinding]] = {} @@ -589,11 +649,16 @@ def _quantity_kind_dispositions( entity = mentions_by_id.get(binding.target_mention_id) if quantity is None or entity is None or quantity.fact_id is None: continue - if not any( - cue.start <= quantity.span.start and entity.span.end <= cue.end - for cue in proposal.evidence_spans - ): - continue + if quantity_entity_proposal is not None: + if not any( + cue.start <= quantity.span.start and entity.span.end <= cue.end + for cue in quantity_entity_proposal.evidence_spans + ): + continue + elif unary_delta_proposal is not None: + cue = unary_delta_proposal.evidence_spans[0] + if quantity.span.start < cue.end or not _spans_are_local(text, cue, entity.span): + continue bound_units = unit_bindings.get(quantity.mention_id, []) if not bound_units: @@ -631,6 +696,7 @@ def _bound_relations( text: str, mentions: tuple[GroundedMention, ...], bindings: tuple[MentionBinding, ...], + proposals: tuple[ConstructionProposal, ...], ) -> tuple[BoundRelation, ...]: by_id = {m.mention_id: m for m in mentions} relations: list[BoundRelation] = [] @@ -699,6 +765,47 @@ def _bound_relations( evidence_spans=(quantity.span, canonical_whole.span, part.span), )) + unary_delta_proposals = tuple( + proposal + for proposal in proposals + if proposal.family_id == "state_change.unary_delta" + ) + if len(unary_delta_proposals) == 1: + unary_match = _UNARY_DELTA_RE.search(text) + proposal = unary_delta_proposals[0] + if unary_match is not None and len(proposal.evidence_spans) == 1: + cue_span = proposal.evidence_spans[0] + cue_surface = text[cue_span.start:cue_span.end] + if cue_span.text == cue_surface and cue_surface in {"gained", "lost"}: + direction = "increase" if cue_surface == "gained" else "decrease" + matching_bindings = [ + binding + for binding in quantity_entity + if by_id[binding.source_mention_id].span.start == unary_match.start("quantity") + and by_id[binding.target_mention_id].span.start == unary_match.start("object") + ] + if len(matching_bindings) == 1: + binding = matching_bindings[0] + quantity = by_id[binding.source_mention_id] + obj = by_id[binding.target_mention_id] + roles = ( + BoundRole( + "action_cue", + f"span:{cue_span.start}:{cue_span.end}", + "span", + (cue_span,), + ), + BoundRole("delta_quantity", quantity.mention_id, quantity.kind, (quantity.span,)), + BoundRole("changed_object", obj.mention_id, obj.kind, (obj.span,)), + BoundRole("direction", direction, "direction", (cue_span,)), + ) + relations.append(BoundRelation( + relation_id="", + relation_type="unary_delta", + roles=roles, + evidence_spans=(cue_span, quantity.span, obj.span), + )) + decrease_matches = list(_DECREASE_TO_FRACTION_RE.finditer(text)) if len(decrease_matches) == 1: match = decrease_matches[0] @@ -939,6 +1046,9 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: ) for proposal in quantity_entity_proposals: builder.add_proposal(proposal) + unary_delta_proposals = _unary_delta_proposals(problem_text) + for proposal in unary_delta_proposals: + builder.add_proposal(proposal) mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units) bindings = _extract_bindings(problem_text, mentions) @@ -951,12 +1061,18 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: for binding in bindings: builder.add_binding(binding) for disposition in _quantity_kind_dispositions( + problem_text, mentions, bindings, - quantity_entity_proposals, + (*quantity_entity_proposals, *unary_delta_proposals), ): builder.add_quantity_kind_disposition(disposition) - for relation in _bound_relations(problem_text, mentions, bindings): + for relation in _bound_relations( + problem_text, + mentions, + bindings, + (*quantity_entity_proposals, *unary_delta_proposals), + ): builder.add_bound_relation(relation) bound_target = _bound_question_target(problem_text, mentions) if bound_target is not None: diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index e6d7c684..af5e6863 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -17,6 +17,7 @@ from generate.construction_affordances import ( _DECREASE_TO_FRACTION_FAMILY, _PERCENT_PARTITION_FAMILY, _QUANTITY_ENTITY_FAMILY, + _UNARY_DELTA_FAMILY, ) from generate.kernel_facts import BoundRelation, GroundedMention, SourceSpan from generate.problem_frame import ProblemFrame @@ -53,6 +54,10 @@ _CONTRACT_REGISTRY: dict[str, ConstructionContract] = { family=_QUANTITY_ENTITY_FAMILY, assess_fn_name="assess_quantity_entity", ), + "unary_delta": ConstructionContract( + family=_UNARY_DELTA_FAMILY, + assess_fn_name="assess_unary_delta", + ), } @@ -99,6 +104,11 @@ def _role_target(relation: BoundRelation, role_name: str) -> str | None: return next((role.target_id for role in relation.roles if role.role == role_name), None) +def _role_spans(relation: BoundRelation, role_name: str) -> tuple[SourceSpan, ...]: + role = next((item for item in relation.roles if item.role == role_name), None) + return () if role is None else role.evidence_spans + + def _mention_map(frame: ProblemFrame) -> dict[str, object]: return {mention.mention_id: mention for mention in frame.mentions} @@ -448,6 +458,115 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment: ) +def assess_unary_delta(frame: ProblemFrame) -> ContractAssessment: + proposals = tuple( + proposal + for proposal in frame.proposals + if proposal.family_id == _UNARY_DELTA_FAMILY.family_id + ) + relations = tuple( + relation + for relation in frame.bound_relations + if relation.relation_type == "unary_delta" + ) + mentions = _mention_map(frame) + dispositions = tuple(frame.quantity_kind_dispositions) + + missing: list[str] = [] + unresolved: set[str] = set() + + if len(proposals) != 1: + missing.append("unary_delta_proposal_required") + proposal = proposals[0] if len(proposals) == 1 else None + + if len(relations) != 1: + missing.append("unary_delta_relation_ambiguous") + relation = relations[0] if len(relations) == 1 else None + + cue_spans = _role_spans(relation, "action_cue") if relation is not None else () + quantity_id = _role_target(relation, "delta_quantity") if relation is not None else None + object_id = _role_target(relation, "changed_object") if relation is not None else None + direction = _role_target(relation, "direction") if relation is not None else None + + if len(cue_spans) != 1: + missing.append("action_cue_unbound") + cue_span = cue_spans[0] if len(cue_spans) == 1 else None + if cue_span is not None and cue_span.text not in {"gained", "lost"}: + missing.append("action_cue_unbound") + + quantity = mentions.get(quantity_id) if quantity_id is not None else None + if quantity is None or quantity.kind != "quantity": + missing.append("delta_quantity_unbound") + + changed_object = mentions.get(object_id) if object_id is not None else None + if changed_object is None or changed_object.kind != "object": + missing.append("changed_object_unbound") + + expected_direction = None + if cue_span is not None: + expected_direction = "increase" if cue_span.text == "gained" else "decrease" + if direction is None or direction != expected_direction: + missing.append("direction_unbound") + + if quantity is not None and changed_object is not None: + matching_dispositions = tuple( + disposition + for disposition in dispositions + if disposition.quantity_mention_id == quantity.mention_id + and disposition.entity_mention_id == changed_object.mention_id + ) + if len(matching_dispositions) != 1: + missing.append("quantity_kind_unresolved") + + exact_evidence = _unique_evidence(tuple( + span + for group in ( + (() if proposal is None else proposal.evidence_spans), + (() if cue_span is None else (cue_span,)), + (() if quantity is None else (quantity.span,)), + (() if changed_object is None else (changed_object.span,)), + ) + for span in group + )) + if proposal is not None and cue_span is not None and proposal.evidence_spans != (cue_span,): + missing.append("provenance_span_inexact") + if relation is not None and quantity is not None and changed_object is not None and cue_span is not None: + if relation.evidence_spans != (cue_span, quantity.span, changed_object.span): + missing.append("provenance_span_inexact") + if not exact_evidence or not all(_span_is_exact(frame, span) for span in exact_evidence): + missing.append("provenance_span_inexact") + + if cue_span is not None and changed_object is not None and not _spans_are_local( + frame.problem_text, + cue_span, + changed_object.span, + ): + missing.append("quantity_entity_nonlocal") + if cue_span is not None and quantity is not None and not _spans_are_local( + frame.problem_text, + cue_span, + quantity.span, + ): + missing.append("quantity_entity_nonlocal") + + missing_bindings = tuple(dict.fromkeys(missing)) + unresolved_hazards = tuple(sorted(unresolved)) + runnable = not missing_bindings and not unresolved_hazards + return ContractAssessment( + candidate_organ="unary_delta", + missing_bindings=missing_bindings, + unresolved_hazards=unresolved_hazards, + runnable=runnable, + explanation=( + "one exact local unary gained/lost delta is grounded diagnostically" + if runnable + else "diagnostic candidate is not runnable: " + + ", ".join((*missing_bindings, *unresolved_hazards)) + ), + evidence_spans=exact_evidence, + ) + + def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment: mentions = {mention.mention_id: mention for mention in frame.mentions} quantities = _quantity_value_by_mention_id(frame) @@ -547,11 +666,15 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: family in ``frame.proposals``. Routes to ``assess_fraction_decrease``, which still determines closure from bound frame evidence. Registry key: ``_CONTRACT_REGISTRY["fraction_decrease"]``. - 3. ``percent_partition`` — triggered by its proposal-first catalog family + 3. ``unary_delta`` — triggered by its proposal-first catalog family in + ``frame.proposals``. Routes to ``assess_unary_delta``, which closes + only exact local gained/lost cue, quantity, and object evidence. + Registry key: ``_CONTRACT_REGISTRY["unary_delta"]``. + 4. ``percent_partition`` — triggered by its proposal-first catalog family in ``frame.proposals``. Routes to ``assess_percent_partition``, which still determines closure from bound frame evidence. Registry key: ``_CONTRACT_REGISTRY["percent_partition"]``. - 4. ``container_packing`` / ``labor_rate`` — inline skeleton assessments; + 5. ``container_packing`` / ``labor_rate`` — inline skeleton assessments; not yet in the catalog registry (added to registry when obligations are fully specified). @@ -570,6 +693,9 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: if _DECREASE_TO_FRACTION_FAMILY.family_id in proposed_family_ids: # Catalog: _CONTRACT_REGISTRY["fraction_decrease"] results.append(assess_fraction_decrease(frame)) + if _UNARY_DELTA_FAMILY.family_id in proposed_family_ids: + # Catalog: _CONTRACT_REGISTRY["unary_delta"] + results.append(assess_unary_delta(frame)) if _PERCENT_PARTITION_FAMILY.family_id in proposed_family_ids: # Catalog: _CONTRACT_REGISTRY["percent_partition"] results.append(assess_percent_partition(frame)) diff --git a/tests/test_construction_affordances.py b/tests/test_construction_affordances.py index ba7223e6..908b7f03 100644 --- a/tests/test_construction_affordances.py +++ b/tests/test_construction_affordances.py @@ -16,7 +16,7 @@ from generate.kernel_facts import SourceSpan def test_catalog_entries_are_diagnostic_only_and_serving_forbidden() -> None: families = all_diagnostic_families() - assert len(families) == 3 + assert len(families) == 4 for family in families: assert family.diagnostic_only is True assert family.serving_allowed is False @@ -30,6 +30,7 @@ def test_catalog_ordering_is_deterministic() -> None: "binding.quantity_entity", "partition.percent_partition", "proportional_change.decrease_to_fraction", + "state_change.unary_delta", ] @@ -49,6 +50,11 @@ def test_lookups_correctness() -> None: assert lookup_by_organ("percent_partition") is partition_family assert lookup_by_relation_type("percent_of") is partition_family + unary_delta = lookup_family("state_change.unary_delta") + assert unary_delta is not None + assert lookup_by_organ("unary_delta") is unary_delta + assert lookup_by_relation_type("unary_delta") is unary_delta + assert lookup_family("invalid_family_id") is None assert lookup_by_organ("invalid_organ") is None assert lookup_by_relation_type("invalid_relation") is None @@ -81,6 +87,12 @@ def test_lookups_correctness() -> None: "percent_partition", {"whole", "part", "scale"}, ), + ( + "state_change.unary_delta", + "unary_delta", + "unary_delta", + {"action_cue", "delta_quantity", "changed_object", "direction"}, + ), ), ) def test_propose_construction_is_preassessment_and_catalog_backed( @@ -111,6 +123,7 @@ def test_propose_construction_is_preassessment_and_catalog_backed( "binding.quantity_entity", "proportional_change.decrease_to_fraction", "partition.percent_partition", + "state_change.unary_delta", ), ) def test_make_proposal_rejects_migrated_proposal_first_families( diff --git a/tests/test_construction_proposal_seam.py b/tests/test_construction_proposal_seam.py index 711d5468..ee5dabb7 100644 --- a/tests/test_construction_proposal_seam.py +++ b/tests/test_construction_proposal_seam.py @@ -36,6 +36,7 @@ PERCENT_PARTITION_CASE = ( ) QUANTITY_ENTITY_CASE = "A school has 100 students." +UNARY_DELTA_CASE = "Ana gained 3 marbles." MIGRATED_CASES = ( ( @@ -53,6 +54,11 @@ MIGRATED_CASES = ( "partition.percent_partition", "percent_partition", ), + ( + UNARY_DELTA_CASE, + "state_change.unary_delta", + "unary_delta", + ), ) @@ -202,6 +208,11 @@ def test_migrated_families_bypass_legacy_make_proposal( "partition.percent_partition", ("20% of", "10% of"), ), + ( + UNARY_DELTA_CASE, + "state_change.unary_delta", + ("gained",), + ), ), ) def test_migrated_proposals_contain_only_exact_motivating_surface_evidence( diff --git a/tests/test_foundational_families.py b/tests/test_foundational_families.py index d506dbb8..d38ebb0e 100644 --- a/tests/test_foundational_families.py +++ b/tests/test_foundational_families.py @@ -19,6 +19,7 @@ from generate.foundational_families import ( EXPECTED_FAMILY_IDS = ( "binding.quantity_entity", "state_change.transition", + "state_change.unary_delta", ) @@ -35,10 +36,12 @@ def test_registry_contains_only_the_two_approved_specs_in_deterministic_order() def test_public_family_accessors() -> None: quantity = get_foundational_family("binding.quantity_entity") state_change = require_foundational_family("state_change.transition") + unary_delta = require_foundational_family("state_change.unary_delta") assert quantity is not None assert quantity.family_id == "binding.quantity_entity" assert state_change.family_id == "state_change.transition" + assert unary_delta.family_id == "state_change.unary_delta" assert get_foundational_family("missing.family") is None with pytest.raises(KeyError, match="missing.family"): @@ -48,9 +51,11 @@ def test_public_family_accessors() -> None: def test_lookup_by_primary_relation_type() -> None: quantity = get_foundational_family_by_relation_type("quantity_entity") state_change = get_foundational_family_by_relation_type("state_change") + unary_delta = get_foundational_family_by_relation_type("unary_delta") assert quantity is get_foundational_family("binding.quantity_entity") assert state_change is get_foundational_family("state_change.transition") + assert unary_delta is get_foundational_family("state_change.unary_delta") assert get_foundational_family_by_relation_type("missing_relation") is None @@ -63,7 +68,7 @@ def test_registry_keys_are_unique() -> None: assert len(relation_types) == len(set(relation_types)) -def test_specs_are_frozen_and_only_quantity_entity_is_authorized() -> None: +def test_specs_are_frozen_and_only_authorized_slices_are_implemented() -> None: for family in iter_foundational_families(): assert isinstance(family, FoundationalFamilySpec) assert family.serving_allowed is False @@ -78,6 +83,9 @@ def test_specs_are_frozen_and_only_quantity_entity_is_authorized() -> None: assert require_foundational_family( "state_change.transition" ).implementation_authorized is False + assert require_foundational_family( + "state_change.unary_delta" + ).implementation_authorized is True def test_required_adr_0224_fields_are_populated() -> None: diff --git a/tests/test_unary_delta_proposal.py b/tests/test_unary_delta_proposal.py new file mode 100644 index 00000000..ca897464 --- /dev/null +++ b/tests/test_unary_delta_proposal.py @@ -0,0 +1,263 @@ +"""Diagnostic-only contract tests for ``state_change.unary_delta``.""" + +from __future__ import annotations + +import ast +import dataclasses +from pathlib import Path + +import generate.problem_frame_contracts as problem_frame_contracts +import pytest + +from generate.kernel_facts import SourceSpan +from generate.problem_frame_builder import build_problem_frame +from generate.problem_frame_contracts import assess_contracts, assess_unary_delta + + +FAMILY_ID = "state_change.unary_delta" +CANDIDATE_ORGAN = "unary_delta" + + +def _proposal(frame): + proposals = tuple( + proposal + for proposal in frame.proposals + if proposal.family_id == FAMILY_ID + ) + assert len(proposals) == 1 + return proposals[0] + + +def _relation(frame): + relations = tuple( + relation + for relation in frame.bound_relations + if relation.relation_type == "unary_delta" + ) + assert len(relations) == 1 + return relations[0] + + +def _assessment(frame): + assessments = tuple( + assessment + for assessment in assess_contracts(frame) + if assessment.candidate_organ == CANDIDATE_ORGAN + ) + assert len(assessments) == 1 + return assessments[0] + + +@pytest.mark.parametrize( + ("problem_text", "cue", "direction"), + ( + ("Ana gained 3 marbles.", "gained", "increase"), + ("The jar lost 2 cookies.", "lost", "decrease"), + ), +) +def test_exact_local_gained_lost_event_is_proposed_bound_and_assessed( + problem_text: str, + cue: str, + direction: str, +) -> None: + frame = build_problem_frame(problem_text) + proposal = _proposal(frame) + relation = _relation(frame) + assessment = _assessment(frame) + + assert proposal.status == "proposed" + assert proposal.diagnostic_only is True + assert proposal.serving_allowed is False + assert not hasattr(proposal, "runnable") + + roles = {role.role: role for role in relation.roles} + assert set(roles) == {"action_cue", "delta_quantity", "changed_object", "direction"} + assert roles["direction"].target_id == direction + assert roles["action_cue"].evidence_spans[0].text == cue + + assert assessment.runnable is True + assert assessment.missing_bindings == () + assert assessment.unresolved_hazards == () + assert all( + span.text == problem_text[span.start:span.end] + for span in (*proposal.evidence_spans, *relation.evidence_spans, *assessment.evidence_spans) + ) + + +def test_builder_publishes_unary_delta_proposal_before_assessment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed_statuses: list[str] = [] + original = problem_frame_contracts.assess_contracts + + def observe(frame): + observed_statuses.append(_proposal(frame).status) + return original(frame) + + monkeypatch.setattr(problem_frame_contracts, "assess_contracts", observe) + + frame = build_problem_frame("Ana gained 3 marbles.") + + assert observed_statuses == ["proposed"] + assert _assessment(frame).runnable is True + + +def test_proposal_free_frame_does_not_dispatch_unary_delta_contract() -> None: + frame = build_problem_frame("Ana gained 3 marbles.") + proposal_free = dataclasses.replace(frame, proposals=()) + + assert CANDIDATE_ORGAN not in { + assessment.candidate_organ + for assessment in assess_contracts(proposal_free) + } + assessment = assess_unary_delta(proposal_free) + assert assessment.runnable is False + assert "unary_delta_proposal_required" in assessment.missing_bindings + + +@pytest.mark.parametrize( + "problem_text", + ( + "There are 12 apples.", + "Tom has 12 apples.", + "Tom gave Ana 3 apples.", + "Tom sent Ana 3 apples.", + "Tom received 3 apples.", + "Tom bought 3 apples.", + "Tom put 3 apples in the box.", + "Tom took 3 apples from the box.", + "Tom had 12 apples and lost 3.", + "There are 12 apples. Tom lost 3.", + "She gained 3 apples.", + "Tom gained apples.", + "Tom gained 3 apples and oranges.", + "Tom gained 3 and 4 apples.", + "Tom gained 3 more apples than Ana.", + "20% of apples were gained.", + "3 apples per child were gained.", + "Tom did not gain 3 apples.", + "Tom may have gained 3 apples.", + "Tom gained 3 apples and lost 2 apples.", + ), +) +def test_confusers_do_not_dispatch_unary_delta(problem_text: str) -> None: + frame = build_problem_frame(problem_text) + + assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals} + assert not any( + relation.relation_type == "unary_delta" + for relation in frame.bound_relations + ) + assert CANDIDATE_ORGAN not in { + assessment.candidate_organ + for assessment in assess_contracts(frame) + } + + +def test_missing_object_literal_surface_stays_unbound_and_non_runnable() -> None: + frame = build_problem_frame("Tom gained 3.") + + assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals} + assert not any( + relation.relation_type == "unary_delta" + for relation in frame.bound_relations + ) + assert CANDIDATE_ORGAN not in { + assessment.candidate_organ + for assessment in assess_contracts(frame) + } + + +def test_missing_quantity_blocks_runnable_unary_delta() -> None: + frame = build_problem_frame("Tom gained 3 apples.") + relation = _relation(frame) + stripped_roles = tuple(role for role in relation.roles if role.role != "delta_quantity") + broken = dataclasses.replace(frame, bound_relations=(dataclasses.replace(relation, roles=stripped_roles),)) + + assessment = assess_unary_delta(broken) + + assert assessment.runnable is False + assert "delta_quantity_unbound" in assessment.missing_bindings + + +def test_quantity_kind_conflict_blocks_runnable_unary_delta() -> None: + frame = build_problem_frame("Tom gained 3 degrees.") + assessment = _assessment(frame) + + assert assessment.runnable is False + assert "quantity_kind_unresolved" in assessment.missing_bindings + + +def test_synthetic_or_mismatched_unary_delta_evidence_refuses() -> None: + frame = build_problem_frame("Ana gained 3 marbles.") + proposal = _proposal(frame) + relation = _relation(frame) + synthetic = SourceSpan("synthetic", 0, 9) + cue = SourceSpan("Ana gained", 0, 10) + bad_relation = dataclasses.replace( + relation, + roles=tuple( + dataclasses.replace(role, evidence_spans=(synthetic,)) + if role.role == "action_cue" + else role + for role in relation.roles + ), + evidence_spans=(cue, *relation.evidence_spans[1:]), + ) + broken = dataclasses.replace( + frame, + proposals=(dataclasses.replace(proposal, evidence_spans=(synthetic,)),), + bound_relations=(bad_relation,), + ) + + assessment = assess_unary_delta(broken) + + assert assessment.runnable is False + assert "provenance_span_inexact" in assessment.missing_bindings + + +def test_unary_delta_replay_is_deterministic() -> None: + problem_text = "Ana gained 3 marbles." + + first = build_problem_frame(problem_text) + second = build_problem_frame(problem_text) + + assert first.proposals == second.proposals + assert first.bound_relations == second.bound_relations + assert assess_contracts(first) == assess_contracts(second) + + +def test_existing_proposal_first_families_do_not_gain_unary_delta_dispatch() -> None: + percent_partition = ( + "A school has 100 students. Half of the students are girls, the other half are boys. " + "20% of the girls have dogs and 10% of the boys have dogs. " + "How many students own dogs?" + ) + fraction_decrease = ( + "A tank's temperature will decrease to 3/4 of its current temperature. " + "If the current temperature is 84 degrees, how many degrees will it decrease by?" + ) + quantity_entity = "A school has 100 students." + + for problem_text in (percent_partition, fraction_decrease, quantity_entity): + frame = build_problem_frame(problem_text) + assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals} + assert CANDIDATE_ORGAN not in { + assessment.candidate_organ + for assessment in assess_contracts(frame) + } + + +def test_unary_delta_path_does_not_import_legacy_semantic_state() -> None: + for module in ( + "generate.problem_frame_builder", + "generate.problem_frame_contracts", + ): + path = Path(__import__(module, fromlist=["__file__"]).__file__) + tree = ast.parse(path.read_text(encoding="utf-8")) + imported = { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module is not None + } + assert "generate.derivation.state" not in imported