feat(kernel): introduce diagnostic quantity-entity proposal seam (#851)

This commit is contained in:
Shay 2026-06-20 20:57:08 -07:00 committed by GitHub
parent ede2450073
commit 5310bd6f3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 790 additions and 19 deletions

View file

@ -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",
})

View file

@ -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",
)

View file

@ -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,
)

View file

@ -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)

View file

@ -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))

View file

@ -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",
),

View file

@ -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",

View file

@ -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 = (

View 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)
}