diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index 9808b87c..51272eec 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -1,9 +1,9 @@ -"""Construction-affordance catalog skeleton. +"""Diagnostic construction-affordance catalog. Formalizes the constructional-affordance pattern proved by PR #835 so future diagnostic families do not become scattered local parser patches. -This module is a catalog/registry skeleton only. It does NOT: +This module contains declarations and proposal factories only. It does NOT: - claim CGA/substrate geometric retrieval (no manifold calls here); - label local regex matching as substrate cognition; - add any acquisition/transaction or transfer/loss/rate/comparison families; @@ -128,7 +128,7 @@ class ConstructionFamily: raise ValueError( f"ConstructionFamily {self.family_id!r}: " "diagnostic_only must be True — " - "this PR registers catalog skeleton entries only." + "catalog constructions do not authorize serving." ) @@ -369,10 +369,95 @@ _PERCENT_PARTITION_FAMILY = ConstructionFamily( serving_allowed=False, ) +_QUANTITY_ENTITY_FAMILY = ConstructionFamily( + family_id="binding.quantity_entity", + display_name="Local quantity-entity binding", + signature=ConstructionSignature( + relation_type="quantity_entity", + candidate_organ="quantity_entity_binding", + required_roles=( + RoleObligation( + "quantity", + required=True, + description="Exactly one source-grounded scalar mention.", + ), + RoleObligation( + "entity", + required=True, + description="Exactly one local source-grounded entity mention.", + ), + RoleObligation( + "quantity_kind", + required=True, + description="A positively grounded count or measurement disposition.", + ), + RoleObligation( + "provenance_span", + required=True, + description="Exact non-synthetic quantity, entity, and optional unit spans.", + ), + RoleObligation( + "local_binding_relation", + required=True, + description="The unique local quantity_entity MentionBinding edge.", + ), + ), + optional_roles=( + RoleObligation( + "unit", + required=False, + description=( + "An exact grounded unit bound to the same quantity when the " + "quantity kind is measurement." + ), + ), + ), + ), + hazards=( + ConstructionHazard( + hazard_category="quantity_entity_ambiguous", + blocking=True, + description="More than one local scalar, entity, or binding remains plausible.", + ), + ConstructionHazard( + hazard_category="quantity_kind_unresolved", + blocking=True, + description="Count or measurement kind is not positively grounded.", + ), + ConstructionHazard( + hazard_category="quantity_entity_nonlocal", + blocking=True, + description="The binding requires pronoun repair or a cross-sentence leap.", + ), + ), + target_semantics=( + "locality:single_sentence", + "authority:diagnostic_only", + ), + contract_labels=( + "quantity_entity_proposal_required", + "quantity_unbound", + "entity_unbound", + "quantity_ambiguous", + "entity_ambiguous", + "local_binding_relation_unbound", + "local_binding_relation_ambiguous", + "quantity_kind_unresolved", + "unit_kind_conflict", + "provenance_span_inexact", + "quantity_entity_nonlocal", + "competing_family_context", + "percent_change_vs_percent_of", + ), + 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, } # Secondary indices for O(1) lookup by organ or relation type. @@ -386,6 +471,7 @@ _BY_RELATION_TYPE: dict[str, ConstructionFamily] = { } _PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset({ + "binding.quantity_entity", "proportional_change.decrease_to_fraction", "partition.percent_partition", }) diff --git a/generate/foundational_families.py b/generate/foundational_families.py index cfeaffe0..5dbb2384 100644 --- a/generate/foundational_families.py +++ b/generate/foundational_families.py @@ -2,8 +2,9 @@ This module manually mirrors the constitutional family specifications approved under ADR-0224. It is descriptive metadata only: it does not read specification -documents, recognize constructions, assess ``ProblemFrame`` contracts, route -proposals, or authorize implementation or serving. +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 @@ -62,7 +63,7 @@ class FoundationalFamilySpec: _QUANTITY_ENTITY_BINDING = FoundationalFamilySpec( family_id="binding.quantity_entity", display_name="Quantity-Entity Binding", - status="Proposed (gating specification)", + status="Implemented (diagnostic-only; non-serving)", related_adrs=("ADR-0223", "ADR-0224"), domains=( "arithmetic_quantitative", @@ -133,16 +134,16 @@ _QUANTITY_ENTITY_BINDING = FoundationalFamilySpec( ), ), current_state=( - "Not implemented as a general family. Local parser heuristics and selected " - "math diagnostic proposal traces exist; general substrate/CGA retrieval does not." + "Implemented only as a bounded diagnostic proposal-first local binding; " + "general entity extraction and substrate/CGA retrieval do not exist." ), target_state=( "Proposal-first quantity-entity binding with span grounding, contract assessment, " "independent verification, and cross-domain evidence." ), - serving_status="Not implemented / not serving.", + serving_status="Diagnostic-only implementation / not serving.", serving_allowed=False, - implementation_authorized=False, + implementation_authorized=True, primary_relation_type="quantity_entity", future_adapter="quantity_entity_adapter", ) diff --git a/generate/problem_frame.py b/generate/problem_frame.py index c32fc622..82685707 100644 --- a/generate/problem_frame.py +++ b/generate/problem_frame.py @@ -9,7 +9,7 @@ arithmetic solvers. from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from generate.kernel_facts import ( @@ -94,6 +94,38 @@ class BoundQuestionTarget: return self.target_mention_id is not None +QuantityKind = Literal["count", "measurement"] + + +@dataclass(frozen=True, slots=True) +class QuantityKindDisposition: + """Closed local count/measurement disposition for one quantity binding. + + This is family-local grounding evidence, not a general entity ontology. + Count dispositions explicitly carry no unit; measurement dispositions + require the exact grounded unit mention that licenses them. + """ + + quantity_mention_id: str + entity_mention_id: str + quantity_kind: QuantityKind + unit_mention_id: str | None + evidence_spans: tuple[SourceSpan, ...] + + def __post_init__(self) -> None: + if self.quantity_kind not in {"count", "measurement"}: + raise ValueError( + "quantity_kind must be 'count' or 'measurement', " + f"got {self.quantity_kind!r}" + ) + if self.quantity_kind == "count" and self.unit_mention_id is not None: + raise ValueError("count quantity dispositions must not carry a unit") + if self.quantity_kind == "measurement" and self.unit_mention_id is None: + raise ValueError("measurement quantity dispositions require a unit") + if not self.evidence_spans: + raise ValueError("quantity kind dispositions require exact evidence spans") + + @dataclass(frozen=True, slots=True) class ProblemFrame: """Immutable target representation of a mathematical word problem. @@ -116,6 +148,8 @@ class ProblemFrame: bound_relations: tuple[BoundRelation, ...] = () bound_question_target: BoundQuestionTarget | None = None proposals: tuple[ConstructionProposal, ...] = () + quantity_kind_dispositions: tuple[QuantityKindDisposition, ...] = () + problem_text: str = "" class ProblemFrameBuilder: @@ -137,6 +171,11 @@ class ProblemFrameBuilder: self._bound_relations: list[BoundRelation] = [] self._bound_question_target: BoundQuestionTarget | None = None self._proposals: list[ConstructionProposal] = [] + self._quantity_kind_dispositions: list[QuantityKindDisposition] = [] + self._problem_text = "" + + def set_problem_text(self, problem_text: str) -> None: + self._problem_text = problem_text def add_quantity(self, scalar: GroundedScalar) -> None: """Add a GroundedScalar to the frame, collecting hazards and provenance.""" @@ -203,6 +242,12 @@ class ProblemFrameBuilder: def add_proposal(self, proposal: ConstructionProposal) -> None: self._proposals.append(proposal) + def add_quantity_kind_disposition( + self, + disposition: QuantityKindDisposition, + ) -> None: + self._quantity_kind_dispositions.append(disposition) + def build(self) -> ProblemFrame: """Produce the immutable ProblemFrame.""" return ProblemFrame( @@ -221,4 +266,6 @@ class ProblemFrameBuilder: bound_relations=tuple(self._bound_relations), bound_question_target=self._bound_question_target, proposals=tuple(self._proposals), + quantity_kind_dispositions=tuple(self._quantity_kind_dispositions), + problem_text=self._problem_text, ) diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index 4b2792fc..a0f9b44d 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -34,6 +34,7 @@ from generate.problem_frame import ( BoundQuestionTarget, ProblemFrame, ProblemFrameBuilder, + QuantityKindDisposition, QuestionTarget, ) from generate.process_frames import ProcessFrame, all_frames @@ -343,6 +344,23 @@ _TRANSFER_RE = re.compile( r"(?P\d+(?:\.\d+)?)\s+(?P[A-Za-z][A-Za-z'-]*)", ) +_QUANTITY_ENTITY_PRONOUNS: frozenset[str] = frozenset({ + "he", "her", "hers", "him", "his", "it", "its", "one", "ones", + "she", "their", "theirs", "them", "these", "they", "this", "those", +}) + +_QUANTITY_ENTITY_CONFUSER_SURFACES: tuple[str, ...] = ( + "each", + "fewer than", + "greater than", + "less than", + "more than", + "per", + "percent", + "percentage", + "ratio", +) + def _proportional_decrease_proposals(text: str) -> tuple[ConstructionProposal, ...]: """Propose the one authorized proposal-first construction from its chunk.""" @@ -387,6 +405,63 @@ def _percent_partition_proposals( ) +def _has_list_or_enumeration_suffix(text: str, end: int) -> bool: + sentence_ends = tuple( + index + for marker in ".!?" + if (index := text.find(marker, end)) != -1 + ) + sentence_end = min(sentence_ends, default=len(text)) + tail = text[end:sentence_end].lstrip().lower() + return tail.startswith((",", ";", "and ", "or ")) + + +def _quantity_entity_proposals( + text: str, + quantities: tuple[GroundedScalar, ...], + frames: tuple[ProcessFrame, ...], +) -> tuple[ConstructionProposal, ...]: + """Propose one narrow local quantity/entity cue from existing extraction. + + The family is intentionally unavailable when another process frame or a + rate/comparison/percent surface is active. Such text needs a different + family to interpret it; this seam never selects the nearest noun. + """ + + if len(quantities) != 1 or frames: + return () + if any( + surface_in_text(surface, text) + for surface in _QUANTITY_ENTITY_CONFUSER_SURFACES + ): + return () + + matches = tuple(_ENTITY_AFTER_QUANTITY_RE.finditer(text)) + if len(matches) != 1: + return () + match = matches[0] + if "%" in match.group("quantity"): + return () + if match.group("entity").lower() in _QUANTITY_ENTITY_PRONOUNS: + return () + if _has_list_or_enumeration_suffix(text, match.end("entity")): + return () + + quantity_span = quantities[0].provenance.source_spans[0] + if ( + quantity_span.start != match.start("quantity") + or quantity_span.end != match.end("quantity") + ): + return () + + evidence = SourceSpan( + text[match.start():match.end()], + match.start(), + match.end(), + ) + return (propose_construction("binding.quantity_entity", (evidence,)),) + + def _extract_mentions( text: str, quantities: tuple[GroundedScalar, ...], @@ -484,6 +559,59 @@ def _extract_bindings( ) for index, b in enumerate(ordered)) +def _quantity_kind_dispositions( + mentions: tuple[GroundedMention, ...], + bindings: tuple[MentionBinding, ...], +) -> tuple[QuantityKindDisposition, ...]: + """Close count/measurement kind only from existing local bindings.""" + + mentions_by_id = {mention.mention_id: mention for mention in mentions} + unit_bindings: dict[str, list[MentionBinding]] = {} + for binding in bindings: + if binding.binding_type == "quantity_unit": + unit_bindings.setdefault(binding.source_mention_id, []).append(binding) + + dispositions: list[QuantityKindDisposition] = [] + for binding in bindings: + if binding.binding_type != "quantity_entity": + continue + quantity = mentions_by_id.get(binding.source_mention_id) + entity = mentions_by_id.get(binding.target_mention_id) + if quantity is None or entity is None or quantity.fact_id is None: + continue + + bound_units = unit_bindings.get(quantity.mention_id, []) + if not bound_units: + dispositions.append(QuantityKindDisposition( + quantity_mention_id=quantity.mention_id, + entity_mention_id=entity.mention_id, + quantity_kind="count", + unit_mention_id=None, + evidence_spans=binding.evidence_spans, + )) + continue + if len(bound_units) != 1: + continue + + unit_binding = bound_units[0] + unit = mentions_by_id.get(unit_binding.target_mention_id) + if unit is None or unit.span == entity.span: + continue + evidence = { + (span.start, span.end, span.text): span + for span in (*binding.evidence_spans, *unit_binding.evidence_spans) + } + dispositions.append(QuantityKindDisposition( + quantity_mention_id=quantity.mention_id, + entity_mention_id=entity.mention_id, + quantity_kind="measurement", + unit_mention_id=unit.mention_id, + evidence_spans=tuple(evidence[key] for key in sorted(evidence)), + )) + + return tuple(dispositions) + + def _bound_relations( text: str, mentions: tuple[GroundedMention, ...], @@ -751,6 +879,7 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: answers or bind case-specific behavior. """ builder = ProblemFrameBuilder() + builder.set_problem_text(problem_text) scalars = _filter_scalar_candidates(problem_text, extract_scalar_candidates(problem_text)) for scalar in scalars: @@ -788,6 +917,12 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: builder.add_proposal(proposal) for proposal in _percent_partition_proposals(problem_text, frames): builder.add_proposal(proposal) + for proposal in _quantity_entity_proposals( + problem_text, + tuple(grounded_quantities), + frames, + ): + builder.add_proposal(proposal) mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units) bindings = _extract_bindings(problem_text, mentions) @@ -799,6 +934,8 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: builder.add_object(mention.surface) for binding in bindings: builder.add_binding(binding) + for disposition in _quantity_kind_dispositions(mentions, bindings): + builder.add_quantity_kind_disposition(disposition) for relation in _bound_relations(problem_text, mentions, bindings): builder.add_bound_relation(relation) bound_target = _bound_question_target(problem_text, mentions) diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index eb75bbb3..e6d7c684 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -1,7 +1,7 @@ """Diagnostic organ-contract readiness derived only from ProblemFrame evidence. Contract dispatch is deliberately narrow: -- ``assess_contracts()`` routes to two diagnostic assessment functions. +- ``assess_contracts()`` routes to three diagnostic assessment functions. - The ``_CONTRACT_REGISTRY`` provides catalog metadata for introspection and proposal-trace generation; it does not replace the structural logic inside each assessment function. @@ -16,8 +16,9 @@ from generate.construction_affordances import ( ConstructionContract, _DECREASE_TO_FRACTION_FAMILY, _PERCENT_PARTITION_FAMILY, + _QUANTITY_ENTITY_FAMILY, ) -from generate.kernel_facts import BoundRelation, SourceSpan +from generate.kernel_facts import BoundRelation, GroundedMention, SourceSpan from generate.problem_frame import ProblemFrame @@ -48,6 +49,10 @@ _CONTRACT_REGISTRY: dict[str, ConstructionContract] = { family=_PERCENT_PARTITION_FAMILY, assess_fn_name="assess_percent_partition", ), + "quantity_entity_binding": ConstructionContract( + family=_QUANTITY_ENTITY_FAMILY, + assess_fn_name="assess_quantity_entity", + ), } @@ -123,6 +128,245 @@ def _quantity_unit_bindings(frame: ProblemFrame) -> dict[str, str]: } +_UNRESOLVED_ENTITY_SURFACES: frozenset[str] = frozenset({ + "he", "her", "hers", "him", "his", "it", "its", "one", "ones", + "she", "their", "theirs", "them", "these", "they", "this", "those", +}) + + +def _span_is_exact(frame: ProblemFrame, span: SourceSpan) -> bool: + return ( + bool(frame.problem_text) + and 0 <= span.start <= span.end <= len(frame.problem_text) + and bool(span.text) + and frame.problem_text[span.start:span.end] == span.text + ) + + +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 _unique_evidence(spans: tuple[SourceSpan, ...]) -> tuple[SourceSpan, ...]: + unique = { + (span.start, span.end, span.text): span + for span in spans + } + return tuple(unique[key] for key in sorted(unique)) + + +def _entity_mentions(frame: ProblemFrame) -> tuple[GroundedMention, ...]: + return tuple( + mention + for mention in frame.mentions + if mention.kind in {"entity", "object", "actor"} + ) + + +def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment: + """Assess one proposal-backed local quantity/entity edge. + + This contract is deliberately stricter than generic mention extraction: it + closes only one exact scalar, one exact entity, one exact local edge, and a + positively grounded count/measurement disposition. It derives neither an + answer nor serving authority. + """ + + proposals = tuple( + proposal + for proposal in frame.proposals + if proposal.family_id == _QUANTITY_ENTITY_FAMILY.family_id + ) + bindings = tuple( + binding + for binding in frame.bindings + if binding.binding_type == "quantity_entity" + ) + mentions = {mention.mention_id: mention for mention in frame.mentions} + quantity_facts = {quantity.fact_id: quantity for quantity in frame.quantities} + + missing: list[str] = [] + unresolved: set[str] = set() + if len(proposals) != 1: + missing.append("quantity_entity_proposal_required") + proposal = proposals[0] if len(proposals) == 1 else None + + if not bindings: + missing.append("local_binding_relation_unbound") + elif len(bindings) != 1: + missing.append("local_binding_relation_ambiguous") + binding = bindings[0] if len(bindings) == 1 else None + + quantity = ( + mentions.get(binding.source_mention_id) + if binding is not None + else None + ) + entity = ( + mentions.get(binding.target_mention_id) + if binding is not None + else None + ) + if quantity is None or quantity.kind != "quantity": + missing.append("quantity_unbound") + elif quantity.fact_id is None or quantity.fact_id not in quantity_facts: + missing.append("quantity_unbound") + if len(frame.quantities) != 1: + missing.append("quantity_ambiguous") + + if entity is None or entity.kind not in {"entity", "object"}: + missing.append("entity_unbound") + elif entity.surface.lower() in _UNRESOLVED_ENTITY_SURFACES: + missing.append("entity_unbound") + unresolved.add("quantity_entity_nonlocal") + + if quantity is not None and entity is not None: + competing_entities = tuple( + mention + for mention in _entity_mentions(frame) + if mention.mention_id != entity.mention_id + and _spans_are_local(frame.problem_text, entity.span, mention.span) + ) + if competing_entities: + missing.append("entity_ambiguous") + if not _spans_are_local(frame.problem_text, quantity.span, entity.span): + missing.append("quantity_entity_nonlocal") + + if proposal is not None and quantity is not None and entity is not None: + cue_contains_binding = any( + cue.start <= quantity.span.start + and entity.span.end <= cue.end + for cue in proposal.evidence_spans + ) + if not cue_contains_binding: + missing.append("local_binding_relation_unbound") + + dispositions = tuple( + disposition + for disposition in frame.quantity_kind_dispositions + if quantity is not None + and entity is not None + and disposition.quantity_mention_id == quantity.mention_id + and disposition.entity_mention_id == entity.mention_id + ) + if len(dispositions) != 1: + missing.append("quantity_kind_unresolved") + disposition = dispositions[0] if len(dispositions) == 1 else None + + unit_bindings = tuple( + binding + for binding in frame.bindings + if quantity is not None + and binding.binding_type == "quantity_unit" + and binding.source_mention_id == quantity.mention_id + ) + if len(unit_bindings) > 1: + missing.append("unit_kind_conflict") + unit_binding = unit_bindings[0] if len(unit_bindings) == 1 else None + unit = ( + mentions.get(unit_binding.target_mention_id) + if unit_binding is not None + else None + ) + if disposition is not None: + if disposition.quantity_kind == "count" and unit_binding is not None: + missing.append("unit_kind_conflict") + elif disposition.quantity_kind == "measurement": + if ( + unit_binding is None + or disposition.unit_mention_id != unit_binding.target_mention_id + or unit is None + or unit.kind != "unit" + ): + missing.append("unit_kind_conflict") + elif unit_binding is not None: + missing.append("unit_kind_conflict") + + if unit is not None and entity is not None and unit.span == entity.span: + missing.append("unit_kind_conflict") + + evidence = _unique_evidence(tuple( + span + for group in ( + (() if proposal is None else proposal.evidence_spans), + (() if binding is None else binding.evidence_spans), + (() if disposition is None else disposition.evidence_spans), + (() if unit_binding is None else unit_binding.evidence_spans), + ) + for span in group + )) + exact_evidence = evidence + if quantity is not None: + exact_evidence = _unique_evidence((*exact_evidence, quantity.span)) + quantity_fact = ( + quantity_facts.get(quantity.fact_id) + if quantity.fact_id is not None + else None + ) + if ( + quantity_fact is None + or quantity.span not in quantity_fact.provenance.source_spans + ): + missing.append("provenance_span_inexact") + if entity is not None: + exact_evidence = _unique_evidence((*exact_evidence, entity.span)) + if unit is not None: + exact_evidence = _unique_evidence((*exact_evidence, unit.span)) + unit_fact = next( + ( + grounded + for grounded in frame.units + if unit.fact_id == grounded.fact_id + ), + None, + ) + if unit_fact is None or unit.span not in unit_fact.provenance.source_spans: + missing.append("provenance_span_inexact") + + if binding is not None and quantity is not None and entity is not None: + if binding.evidence_spans != (quantity.span, entity.span): + missing.append("provenance_span_inexact") + if unit_binding is not None and quantity is not None and unit is not None: + if unit_binding.evidence_spans != (quantity.span, unit.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") + + competing_families = {candidate.name for candidate in frame.process_frames} + if competing_families: + missing.append("competing_family_context") + categories = {hazard.category for hazard in frame.hazards} + if "percent_change_vs_percent_of" in categories: + unresolved.add("percent_change_vs_percent_of") + + missing_bindings = tuple(dict.fromkeys(missing)) + unresolved_hazards = tuple(sorted(unresolved)) + runnable = not missing_bindings and not unresolved_hazards + return ContractAssessment( + candidate_organ="quantity_entity_binding", + missing_bindings=missing_bindings, + unresolved_hazards=unresolved_hazards, + runnable=runnable, + explanation=( + "one exact local quantity/entity binding is grounded diagnostically" + if runnable + else "diagnostic candidate is not runnable: " + + ", ".join((*missing_bindings, *unresolved_hazards)) + ), + evidence_spans=exact_evidence, + ) + + def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment: mentions = _mention_map(frame) quantities = _quantity_value_by_mention_id(frame) @@ -295,15 +539,19 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: """Return deterministic diagnostic assessments; never admits serving. Dispatch order: - 1. ``decrease_to_fraction`` — triggered by its proposal-first catalog + 1. ``quantity_entity`` — triggered by its proposal-first foundational + family in ``frame.proposals``. Routes to + ``assess_quantity_entity``, which closes only exact local evidence. + Registry key: ``_CONTRACT_REGISTRY["quantity_entity_binding"]``. + 2. ``decrease_to_fraction`` — triggered by its proposal-first catalog family in ``frame.proposals``. Routes to ``assess_fraction_decrease``, which still determines closure from bound frame evidence. Registry key: ``_CONTRACT_REGISTRY["fraction_decrease"]``. - 2. ``percent_partition`` — triggered by its proposal-first catalog family + 3. ``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"]``. - 3. ``container_packing`` / ``labor_rate`` — inline skeleton assessments; + 4. ``container_packing`` / ``labor_rate`` — inline skeleton assessments; not yet in the catalog registry (added to registry when obligations are fully specified). @@ -316,6 +564,9 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: # Registry-backed diagnostic families proposed_family_ids = {proposal.family_id for proposal in frame.proposals} + if _QUANTITY_ENTITY_FAMILY.family_id in proposed_family_ids: + # Catalog: _CONTRACT_REGISTRY["quantity_entity_binding"] + results.append(assess_quantity_entity(frame)) if _DECREASE_TO_FRACTION_FAMILY.family_id in proposed_family_ids: # Catalog: _CONTRACT_REGISTRY["fraction_decrease"] results.append(assess_fraction_decrease(frame)) diff --git a/tests/test_construction_affordances.py b/tests/test_construction_affordances.py index b7532862..85e6d5e8 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) == 2 + assert len(families) == 3 for family in families: assert family.diagnostic_only is True assert family.serving_allowed is False @@ -27,12 +27,18 @@ def test_catalog_ordering_is_deterministic() -> None: ids = [f.family_id for f in families] assert ids == sorted(ids) assert ids == [ + "binding.quantity_entity", "partition.percent_partition", "proportional_change.decrease_to_fraction", ] def test_lookups_correctness() -> None: + quantity_entity = lookup_family("binding.quantity_entity") + assert quantity_entity is not None + assert lookup_by_organ("quantity_entity_binding") is quantity_entity + assert lookup_by_relation_type("quantity_entity") is quantity_entity + fraction_family = lookup_family("proportional_change.decrease_to_fraction") assert fraction_family is not None assert lookup_by_organ("fraction_decrease") is fraction_family @@ -51,6 +57,18 @@ def test_lookups_correctness() -> None: @pytest.mark.parametrize( ("family_id", "relation_type", "candidate_organ", "required_roles"), ( + ( + "binding.quantity_entity", + "quantity_entity", + "quantity_entity_binding", + { + "quantity", + "entity", + "quantity_kind", + "provenance_span", + "local_binding_relation", + }, + ), ( "proportional_change.decrease_to_fraction", "decrease_to_fraction", @@ -90,6 +108,7 @@ def test_propose_construction_is_preassessment_and_catalog_backed( @pytest.mark.parametrize( "family_id", ( + "binding.quantity_entity", "proportional_change.decrease_to_fraction", "partition.percent_partition", ), diff --git a/tests/test_construction_proposal_seam.py b/tests/test_construction_proposal_seam.py index 62adcdb5..711d5468 100644 --- a/tests/test_construction_proposal_seam.py +++ b/tests/test_construction_proposal_seam.py @@ -35,7 +35,14 @@ PERCENT_PARTITION_CASE = ( "How many students own dogs?" ) +QUANTITY_ENTITY_CASE = "A school has 100 students." + MIGRATED_CASES = ( + ( + QUANTITY_ENTITY_CASE, + "binding.quantity_entity", + "quantity_entity_binding", + ), ( FRACTION_DECREASE_CASE, "proportional_change.decrease_to_fraction", @@ -180,6 +187,11 @@ def test_migrated_families_bypass_legacy_make_proposal( @pytest.mark.parametrize( ("problem_text", "family_id", "expected_evidence"), ( + ( + QUANTITY_ENTITY_CASE, + "binding.quantity_entity", + ("100 students",), + ), ( TANK_EVIDENCE_CASE, "proportional_change.decrease_to_fraction", diff --git a/tests/test_foundational_families.py b/tests/test_foundational_families.py index 0937a983..d506dbb8 100644 --- a/tests/test_foundational_families.py +++ b/tests/test_foundational_families.py @@ -63,16 +63,22 @@ def test_registry_keys_are_unique() -> None: assert len(relation_types) == len(set(relation_types)) -def test_specs_are_frozen_and_explicitly_not_authorized() -> None: +def test_specs_are_frozen_and_only_quantity_entity_is_authorized() -> None: for family in iter_foundational_families(): assert isinstance(family, FoundationalFamilySpec) assert family.serving_allowed is False - assert family.implementation_authorized is False assert "not serving" in family.serving_status.lower() with pytest.raises(FrozenInstanceError): family.display_name = "mutated" # type: ignore[misc] + assert require_foundational_family( + "binding.quantity_entity" + ).implementation_authorized is True + assert require_foundational_family( + "state_change.transition" + ).implementation_authorized is False + def test_required_adr_0224_fields_are_populated() -> None: required_fields = ( diff --git a/tests/test_quantity_entity_proposal.py b/tests/test_quantity_entity_proposal.py new file mode 100644 index 00000000..9102fb57 --- /dev/null +++ b/tests/test_quantity_entity_proposal.py @@ -0,0 +1,212 @@ +"""Diagnostic-only contract tests for ``binding.quantity_entity``.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +import generate.problem_frame_contracts as problem_frame_contracts +from generate.kernel_facts import SourceSpan +from generate.problem_frame import QuantityKindDisposition +from generate.problem_frame_builder import build_problem_frame +from generate.problem_frame_contracts import assess_contracts, assess_quantity_entity + + +FAMILY_ID = "binding.quantity_entity" +CANDIDATE_ORGAN = "quantity_entity_binding" + + +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 _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", + ( + "A school has 100 students.", + "There are 12 apples in the basket.", + ), +) +def test_exact_local_count_binding_is_proposed_then_assessed( + problem_text: str, +) -> None: + frame = build_problem_frame(problem_text) + proposal = _proposal(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") + assert assessment.runnable is True + assert assessment.missing_bindings == () + assert assessment.unresolved_hazards == () + assert len(frame.quantity_kind_dispositions) == 1 + assert frame.quantity_kind_dispositions[0].quantity_kind == "count" + assert frame.quantity_kind_dispositions[0].unit_mention_id is None + assert all( + span.text == problem_text[span.start:span.end] + for span in (*proposal.evidence_spans, *assessment.evidence_spans) + ) + + +def test_builder_publishes_quantity_entity_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("A school has 100 students.") + + assert observed_statuses == ["proposed"] + assert _assessment(frame).runnable is True + + +def test_proposal_free_frame_does_not_dispatch_quantity_entity_contract() -> None: + frame = build_problem_frame("A school has 100 students.") + proposal_free = dataclasses.replace(frame, proposals=()) + + assert CANDIDATE_ORGAN not in { + assessment.candidate_organ + for assessment in assess_contracts(proposal_free) + } + assert assess_quantity_entity(proposal_free).runnable is False + assert ( + "quantity_entity_proposal_required" + in assess_quantity_entity(proposal_free).missing_bindings + ) + + +@pytest.mark.parametrize( + "problem_text", + ( + "There are 12.", + "Students wait in the hall.", + "There are 12 apples and oranges.", + "There are 12 and 13 apples.", + "A school has 20% of the students.", + "A basket holds 3 apples per child.", + "There are 3 more than 2 apples.", + "Tom gave Ana 3 marbles.", + "There are 12 of them.", + "There are 12. Apples fill the basket.", + "There are 12 apples, oranges, and pears.", + ), +) +def test_confusers_do_not_dispatch_quantity_entity(problem_text: str) -> None: + 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_state_change_surface_does_not_backdoor_foundational_families() -> None: + frame = build_problem_frame("Tom gave Ana 3 marbles.") + + assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals} + assert "state_change.transition" not in { + proposal.family_id for proposal in frame.proposals + } + + +def test_unit_entity_span_conflict_refuses_measurement_claim() -> None: + frame = build_problem_frame("The tank has 84 degrees.") + assessment = _assessment(frame) + + assert assessment.runnable is False + assert "quantity_kind_unresolved" in assessment.missing_bindings + assert "unit_kind_conflict" in assessment.missing_bindings + + +def test_synthetic_or_text_mismatched_proposal_evidence_refuses() -> None: + frame = build_problem_frame("A school has 100 students.") + proposal = _proposal(frame) + synthetic = SourceSpan("synthetic", 0, 9) + frame = dataclasses.replace( + frame, + proposals=(dataclasses.replace(proposal, evidence_spans=(synthetic,)),), + ) + + assessment = assess_quantity_entity(frame) + + assert assessment.runnable is False + assert "provenance_span_inexact" in assessment.missing_bindings + + +def test_quantity_kind_disposition_rejects_illegal_unit_states() -> None: + span = SourceSpan("12", 0, 2) + + with pytest.raises(ValueError, match="count.*must not carry a unit"): + QuantityKindDisposition( + "quantity", + "entity", + "count", + "unit", + (span,), + ) + with pytest.raises(ValueError, match="measurement.*require a unit"): + QuantityKindDisposition( + "quantity", + "entity", + "measurement", + None, + (span,), + ) + + +def test_quantity_entity_replay_is_deterministic() -> None: + problem_text = "A school has 100 students." + + first = build_problem_frame(problem_text) + second = build_problem_frame(problem_text) + + assert first.proposals == second.proposals + assert first.bindings == second.bindings + assert first.quantity_kind_dispositions == second.quantity_kind_dispositions + assert assess_contracts(first) == assess_contracts(second) + + +def test_existing_proposal_first_families_do_not_gain_quantity_entity_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?" + ) + + for problem_text in (percent_partition, fraction_decrease): + 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) + } \ No newline at end of file