feat(kernel): introduce diagnostic quantity-entity proposal seam (#851)
This commit is contained in:
parent
ede2450073
commit
5310bd6f3c
9 changed files with 790 additions and 19 deletions
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<quantity>\d+(?:\.\d+)?)\s+(?P<object>[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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
212
tests/test_quantity_entity_proposal.py
Normal file
212
tests/test_quantity_entity_proposal.py
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in a new issue