fix(kernel): align unary-delta implementation with conformance decision

This commit is contained in:
Shay 2026-06-21 20:19:53 -07:00
parent d336ed6a38
commit 61964c5cd4
8 changed files with 849 additions and 341 deletions

View file

@ -463,7 +463,7 @@ _UNARY_DELTA_FAMILY = ConstructionFamily(
display_name="Unary gained/lost delta",
signature=ConstructionSignature(
relation_type="unary_delta",
candidate_organ="unary_delta",
candidate_organ="unary_delta_transition",
required_roles=(
RoleObligation(
"action_cue",

View file

@ -288,7 +288,7 @@ _UNARY_DELTA = FoundationalFamilySpec(
"ConstructionProposal(status='proposed', family_id='state_change.unary_delta')",
"BoundRelation(relation_type='unary_delta') with action_cue, delta_quantity, changed_object, and direction roles",
"Exact SourceSpan evidence for cue, quantity, and object",
"ContractAssessment(candidate_organ='unary_delta') as the sole runnable/refused authority",
"ContractAssessment(candidate_organ='unary_delta_transition') as the sole runnable/refused authority",
),
contract_readiness_criteria=(
"Exactly one local gained/lost cue is grounded from the original text.",

View file

@ -126,6 +126,29 @@ class QuantityKindDisposition:
raise ValueError("quantity kind dispositions require exact evidence spans")
UnaryDeltaDirection = Literal["increase", "decrease"]
UnaryDeltaActionKind = Literal["gain", "loss"]
@dataclass(frozen=True, slots=True)
class GroundedUnaryDeltaCue:
cue_id: str
surface: str
action_kind: UnaryDeltaActionKind
direction: UnaryDeltaDirection
span: SourceSpan
def __post_init__(self) -> None:
if self.action_kind not in {"gain", "loss"}:
raise ValueError(f"invalid action_kind: {self.action_kind!r}")
if self.direction not in {"increase", "decrease"}:
raise ValueError(f"invalid direction: {self.direction!r}")
if self.surface != self.span.text:
raise ValueError(
f"surface {self.surface!r} must match span text {self.span.text!r}"
)
@dataclass(frozen=True, slots=True)
class ProblemFrame:
"""Immutable target representation of a mathematical word problem.
@ -149,6 +172,7 @@ class ProblemFrame:
bound_question_target: BoundQuestionTarget | None = None
proposals: tuple[ConstructionProposal, ...] = ()
quantity_kind_dispositions: tuple[QuantityKindDisposition, ...] = ()
unary_delta_cues: tuple[GroundedUnaryDeltaCue, ...] = ()
problem_text: str = ""
@ -172,6 +196,7 @@ class ProblemFrameBuilder:
self._bound_question_target: BoundQuestionTarget | None = None
self._proposals: list[ConstructionProposal] = []
self._quantity_kind_dispositions: list[QuantityKindDisposition] = []
self._unary_delta_cues: list[GroundedUnaryDeltaCue] = []
self._problem_text = ""
def set_problem_text(self, problem_text: str) -> None:
@ -248,6 +273,9 @@ class ProblemFrameBuilder:
) -> None:
self._quantity_kind_dispositions.append(disposition)
def add_unary_delta_cue(self, cue: GroundedUnaryDeltaCue) -> None:
self._unary_delta_cues.append(cue)
def build(self) -> ProblemFrame:
"""Produce the immutable ProblemFrame."""
return ProblemFrame(
@ -267,5 +295,6 @@ class ProblemFrameBuilder:
bound_question_target=self._bound_question_target,
proposals=tuple(self._proposals),
quantity_kind_dispositions=tuple(self._quantity_kind_dispositions),
unary_delta_cues=tuple(self._unary_delta_cues),
problem_text=self._problem_text,
)

View file

@ -10,6 +10,7 @@ Non-goals:
- serving admission
- guessing unsupported or ambiguous surfaces
"""
from __future__ import annotations
import dataclasses
@ -32,6 +33,7 @@ from generate.kernel_facts import (
)
from generate.problem_frame import (
BoundQuestionTarget,
GroundedUnaryDeltaCue,
ProblemFrame,
ProblemFrameBuilder,
QuantityKindDisposition,
@ -51,11 +53,32 @@ from language_packs.unit_dimensions import classify_dimension
_UNIT_TOKEN_RE: re.Pattern[str] = re.compile(r"\b\d+(?:\.\d+)?\s+([a-zA-Z]+)\b")
_UNIT_STOPWORDS: frozenset[str] = frozenset({
"more", "less", "times", "percent", "percentage", "of", "and", "or",
"the", "a", "an", "in", "to", "for", "with", "at", "by", "from",
"each", "per", "way", "ways",
})
_UNIT_STOPWORDS: frozenset[str] = frozenset(
{
"more",
"less",
"times",
"percent",
"percentage",
"of",
"and",
"or",
"the",
"a",
"an",
"in",
"to",
"for",
"with",
"at",
"by",
"from",
"each",
"per",
"way",
"ways",
}
)
_ORDINAL_SUFFIX_RE: re.Pattern[str] = re.compile(
r"\b(half|third|quarter)\s+(place|position|grade|rank)\b",
@ -65,11 +88,14 @@ _ORDINAL_SUFFIX_RE: re.Pattern[str] = re.compile(
def surface_in_text(surface: str, text: str) -> bool:
"""Match a registered surface at lexical, including punctuation, boundaries."""
return re.search(
rf"(?<![\w]){re.escape(surface)}(?![\w])",
text,
flags=re.IGNORECASE,
) is not None
return (
re.search(
rf"(?<![\w]){re.escape(surface)}(?![\w])",
text,
flags=re.IGNORECASE,
)
is not None
)
def _hazard_to_kernel(hazard: AmbiguityHazard) -> KernelHazard:
@ -112,7 +138,9 @@ def _extract_unit_candidates(text: str) -> tuple[GroundedUnit, ...]:
)
)
return tuple(sorted(units, key=lambda u: (u.provenance.source_spans[0].start, u.surface)))
return tuple(
sorted(units, key=lambda u: (u.provenance.source_spans[0].start, u.surface))
)
def _extract_hazards(text: str) -> tuple[KernelHazard, ...]:
@ -176,7 +204,7 @@ def _trigger_span(text: str, trigger: str) -> SourceSpan | None:
)
if match is None:
return None
return SourceSpan(text[match.start():match.end()], match.start(), match.end())
return SourceSpan(text[match.start() : match.end()], match.start(), match.end())
def _sentence_contains_current_or_now(text: str, index: int) -> bool:
@ -186,7 +214,8 @@ def _sentence_contains_current_or_now(text: str, index: int) -> bool:
text.rfind("!", 0, index),
)
end_candidates = [
pos for pos in (
pos
for pos in (
text.find(".", index),
text.find("?", index),
text.find("!", index),
@ -194,7 +223,7 @@ def _sentence_contains_current_or_now(text: str, index: int) -> bool:
if pos != -1
]
end = min(end_candidates) if end_candidates else len(text)
sentence = text[start + 1:end].lower()
sentence = text[start + 1 : end].lower()
return "current" in sentence or "now" in sentence
@ -350,10 +379,27 @@ _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_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",
@ -375,7 +421,7 @@ def _proportional_decrease_proposals(text: str) -> tuple[ConstructionProposal, .
return ()
match = matches[0]
evidence = SourceSpan(
text[match.start():match.end()],
text[match.start() : match.end()],
match.start(),
match.end(),
)
@ -397,7 +443,7 @@ def _percent_partition_proposals(
return ()
evidence_spans = tuple(
SourceSpan(text[match.start():match.end()], match.start(), match.end())
SourceSpan(text[match.start() : match.end()], match.start(), match.end())
for match in _PERCENT_OF_PROPOSAL_RE.finditer(text)
)
if not evidence_spans:
@ -413,9 +459,7 @@ 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
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()
@ -430,7 +474,7 @@ def _spans_are_local(
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 ".!?")
return not any(marker in problem_text[left.end : right.start] for marker in ".!?")
def _quantity_entity_proposals(
@ -448,8 +492,7 @@ def _quantity_entity_proposals(
if len(quantities) != 1 or frames:
return ()
if any(
surface_in_text(surface, text)
for surface in _QUANTITY_ENTITY_CONFUSER_SURFACES
surface_in_text(surface, text) for surface in _QUANTITY_ENTITY_CONFUSER_SURFACES
):
return ()
@ -465,14 +508,13 @@ def _quantity_entity_proposals(
return ()
quantity_span = quantities[0].provenance.source_spans[0]
if (
quantity_span.start != match.start("quantity")
or quantity_span.end != match.end("quantity")
if quantity_span.start != match.start("quantity") or quantity_span.end != match.end(
"quantity"
):
return ()
evidence = SourceSpan(
text[match.start():match.end()],
text[match.start() : match.end()],
match.start(),
match.end(),
)
@ -483,34 +525,83 @@ def _unary_delta_proposals(
text: str,
) -> tuple[ConstructionProposal, ...]:
"""Propose the narrow gained/lost unary-delta slice from exact local cues."""
if any(
surface_in_text(surface, text)
for surface in _QUANTITY_ENTITY_CONFUSER_SURFACES
):
return ()
matches = tuple(_UNARY_DELTA_RE.finditer(text))
matches = list(re.finditer(r"\b(gained|lost)\b", text))
if len(matches) != 1:
return ()
match = matches[0]
if match.group("subject").lower() in _QUANTITY_ENTITY_PRONOUNS:
# Block if there are multiple sentences
clean_text = re.sub(r"\d+\.\d+", "", text)
trimmed = clean_text.strip()
if trimmed and trimmed[-1] in ".!?":
trimmed = trimmed[:-1]
if any(marker in trimmed for marker in ".!?"):
return ()
if _has_list_or_enumeration_suffix(text, match.end("object")):
# Competing / blocking surfaces
confusers = {
"percent",
"percentage",
"%",
"per",
"each",
"ratio",
"more than",
"less than",
"fewer than",
"greater than",
"times as",
}
if any(c in text.lower() for c in confusers):
return ()
sentence_ends = tuple(
index
for marker in ".!?"
if (index := text.find(marker, match.end("object"))) != -1
)
sentence_end = min(sentence_ends, default=len(text))
trailing = text[match.end("object"):sentence_end].strip()
if trailing:
# Transfer / transaction verbs
transfer_verbs = {
"gave",
"give",
"gives",
"handed",
"passed",
"sent",
"send",
"sends",
"received",
"receives",
"bought",
"buys",
"sold",
"sells",
"spent",
"spends",
"ate",
"eats",
}
if any(re.search(rf"\b{verb}\b", text.lower()) for verb in transfer_verbs):
return ()
# Containment verbs
containment_verbs = {
"put",
"took",
"moved",
"filled",
}
if any(re.search(rf"\b{verb}\b", text.lower()) for verb in containment_verbs):
return ()
# Before / after state keywords
before_after = {"had", "was", "became", "originally", "now has"}
if any(re.search(rf"\b{word}\b", text.lower()) for word in before_after):
return ()
# List coordination / enumeration
if any(coord in text.lower() for coord in {"and", "or", ","}):
return ()
evidence = SourceSpan(
text[match.start("cue"):match.end("cue")],
match.start("cue"),
match.end("cue"),
text[match.start() : match.end()],
match.start(),
match.end(),
)
return (propose_construction("state_change.unary_delta", (evidence,)),)
@ -527,8 +618,11 @@ def _extract_mentions(
if key in records:
return
records[key] = GroundedMention(
mention_id="", kind=kind, surface=text[start:end],
span=SourceSpan(text[start:end], start, end), fact_id=fact_id,
mention_id="",
kind=kind,
surface=text[start:end],
span=SourceSpan(text[start:end], start, end),
fact_id=fact_id,
)
for quantity in quantities:
@ -537,7 +631,11 @@ def _extract_mentions(
for unit in units:
span = unit.provenance.source_spans[0]
add("unit", span.start, span.end, fact_id=unit.fact_id)
for pattern in (_ENTITY_AFTER_QUANTITY_RE, _FRACTION_ENTITY_RE, _QUESTION_ENTITY_RE):
for pattern in (
_ENTITY_AFTER_QUANTITY_RE,
_FRACTION_ENTITY_RE,
_QUESTION_ENTITY_RE,
):
for match in pattern.finditer(text):
add("object", match.start("entity"), match.end("entity"))
for match in _COPULAR_PARTITION_RE.finditer(text):
@ -558,8 +656,11 @@ def _extract_mentions(
)
return tuple(
GroundedMention(
mention_id=f"mention-{index:04d}", kind=m.kind, surface=m.surface,
span=m.span, fact_id=m.fact_id,
mention_id=f"mention-{index:04d}",
kind=m.kind,
surface=m.surface,
span=m.span,
fact_id=m.fact_id,
)
for index, m in enumerate(ordered)
)
@ -574,23 +675,33 @@ def _extract_bindings(
bindings: list[MentionBinding] = []
seen: set[tuple[str, str, str]] = set()
def bind(binding_type: str, source: GroundedMention, target: GroundedMention) -> None:
def bind(
binding_type: str, source: GroundedMention, target: GroundedMention
) -> None:
key = (binding_type, source.mention_id, target.mention_id)
if key in seen:
return
seen.add(key)
bindings.append(MentionBinding(
binding_id="", binding_type=binding_type,
source_mention_id=source.mention_id, target_mention_id=target.mention_id,
evidence_spans=(source.span, target.span),
))
bindings.append(
MentionBinding(
binding_id="",
binding_type=binding_type,
source_mention_id=source.mention_id,
target_mention_id=target.mention_id,
evidence_spans=(source.span, target.span),
)
)
for pattern in (_ENTITY_AFTER_QUANTITY_RE, _FRACTION_ENTITY_RE):
for match in pattern.finditer(text):
entity = by_span_kind.get((match.start("entity"), match.end("entity"), "object"))
entity = by_span_kind.get(
(match.start("entity"), match.end("entity"), "object")
)
if entity is None:
continue
candidates = [q for q in quantities if q.span.start == match.start("quantity")]
candidates = [
q for q in quantities if q.span.start == match.start("quantity")
]
if candidates:
bind("quantity_entity", candidates[0], entity)
units = [m for m in mentions if m.kind == "unit"]
@ -599,17 +710,25 @@ def _extract_bindings(
unit
for unit in units
if unit.span.start >= quantity.span.end
and not text[quantity.span.end:unit.span.start].strip()
and not text[quantity.span.end : unit.span.start].strip()
]
if following:
bind("quantity_unit", quantity, min(following, key=lambda u: u.span.start))
ordered = sorted(bindings, key=lambda b: (b.evidence_spans[0].start, b.binding_type, b.target_mention_id))
return tuple(MentionBinding(
binding_id=f"binding-{index:04d}", binding_type=b.binding_type,
source_mention_id=b.source_mention_id, target_mention_id=b.target_mention_id,
evidence_spans=b.evidence_spans,
) for index, b in enumerate(ordered))
ordered = sorted(
bindings,
key=lambda b: (b.evidence_spans[0].start, b.binding_type, b.target_mention_id),
)
return tuple(
MentionBinding(
binding_id=f"binding-{index:04d}",
binding_type=b.binding_type,
source_mention_id=b.source_mention_id,
target_mention_id=b.target_mention_id,
evidence_spans=b.evidence_spans,
)
for index, b in enumerate(ordered)
)
def _quantity_kind_dispositions(
@ -625,15 +744,9 @@ def _quantity_kind_dispositions(
for proposal in proposals
if proposal.family_id == "binding.quantity_entity"
)
unary_delta_proposals = tuple(
proposal
for proposal in proposals
if proposal.family_id == "state_change.unary_delta"
)
if len(quantity_entity_proposals) + len(unary_delta_proposals) != 1:
if len(quantity_entity_proposals) != 1:
return ()
quantity_entity_proposal = quantity_entity_proposals[0] if quantity_entity_proposals else None
unary_delta_proposal = unary_delta_proposals[0] if unary_delta_proposals else None
quantity_entity_proposal = quantity_entity_proposals[0]
mentions_by_id = {mention.mention_id: mention for mention in mentions}
unit_bindings: dict[str, list[MentionBinding]] = {}
@ -649,26 +762,23 @@ def _quantity_kind_dispositions(
entity = mentions_by_id.get(binding.target_mention_id)
if quantity is None or entity is None or quantity.fact_id is None:
continue
if quantity_entity_proposal is not None:
if not any(
cue.start <= quantity.span.start and entity.span.end <= cue.end
for cue in quantity_entity_proposal.evidence_spans
):
continue
elif unary_delta_proposal is not None:
cue = unary_delta_proposal.evidence_spans[0]
if quantity.span.start < cue.end or not _spans_are_local(text, cue, entity.span):
continue
if not any(
cue.start <= quantity.span.start and entity.span.end <= cue.end
for cue in quantity_entity_proposal.evidence_spans
):
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,
))
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
@ -681,13 +791,15 @@ def _quantity_kind_dispositions(
(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)),
))
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)
@ -718,52 +830,107 @@ def _bound_relations(
(
mention
for mention in mentions
if mention.kind == part.kind and mention.surface.lower() == part.surface.lower()
if mention.kind == part.kind
and mention.surface.lower() == part.surface.lower()
),
key=lambda mention: mention.span.start,
default=part,
)
if "%" not in quantity.surface and quantity.surface.lower() not in {"half", "third", "quarter"}:
if "%" not in quantity.surface and quantity.surface.lower() not in {
"half",
"third",
"quarter",
}:
continue
roles = [
BoundRole("part", canonical_part.mention_id, canonical_part.kind, (canonical_part.span,)),
BoundRole(
"part",
canonical_part.mention_id,
canonical_part.kind,
(canonical_part.span,),
),
BoundRole("scale", quantity.mention_id, quantity.kind, (quantity.span,)),
]
if whole is not None:
whole_entity = by_id[whole.target_mention_id]
roles.insert(0, BoundRole("whole", whole_entity.mention_id, whole_entity.kind, (whole_entity.span,)))
relation_type = "percent_of" if "%" in quantity.surface else "subgroup_partition"
relations.append(BoundRelation(
relation_id="", relation_type=relation_type, roles=tuple(roles),
evidence_spans=tuple(span for role in roles for span in role.evidence_spans),
))
roles.insert(
0,
BoundRole(
"whole",
whole_entity.mention_id,
whole_entity.kind,
(whole_entity.span,),
),
)
relation_type = (
"percent_of" if "%" in quantity.surface else "subgroup_partition"
)
relations.append(
BoundRelation(
relation_id="",
relation_type=relation_type,
roles=tuple(roles),
evidence_spans=tuple(
span for role in roles for span in role.evidence_spans
),
)
)
for match in _COPULAR_PARTITION_RE.finditer(text):
quantity = next((m for m in mentions if m.kind == "quantity" and m.span.start == match.start("quantity")), None)
whole = next((m for m in mentions if m.kind == "object" and m.span.start == match.start("whole")), None)
part = next((m for m in mentions if m.kind == "object" and m.span.start == match.start("part")), None)
quantity = next(
(
m
for m in mentions
if m.kind == "quantity" and m.span.start == match.start("quantity")
),
None,
)
whole = next(
(
m
for m in mentions
if m.kind == "object" and m.span.start == match.start("whole")
),
None,
)
part = next(
(
m
for m in mentions
if m.kind == "object" and m.span.start == match.start("part")
),
None,
)
if quantity is None or whole is None or part is None:
continue
canonical_whole = min(
(
mention
for mention in mentions
if mention.kind == "object" and mention.surface.lower() == whole.surface.lower()
if mention.kind == "object"
and mention.surface.lower() == whole.surface.lower()
),
key=lambda mention: mention.span.start,
default=whole,
)
roles = (
BoundRole("whole", canonical_whole.mention_id, canonical_whole.kind, (canonical_whole.span,)),
BoundRole(
"whole",
canonical_whole.mention_id,
canonical_whole.kind,
(canonical_whole.span,),
),
BoundRole("part", part.mention_id, part.kind, (part.span,)),
BoundRole("scale", quantity.mention_id, quantity.kind, (quantity.span,)),
)
relations.append(BoundRelation(
relation_id="",
relation_type="subgroup_partition",
roles=roles,
evidence_spans=(quantity.span, canonical_whole.span, part.span),
))
relations.append(
BoundRelation(
relation_id="",
relation_type="subgroup_partition",
roles=roles,
evidence_spans=(quantity.span, canonical_whole.span, part.span),
)
)
unary_delta_proposals = tuple(
proposal
@ -771,45 +938,67 @@ def _bound_relations(
if proposal.family_id == "state_change.unary_delta"
)
if len(unary_delta_proposals) == 1:
unary_match = _UNARY_DELTA_RE.search(text)
proposal = unary_delta_proposals[0]
if unary_match is not None and len(proposal.evidence_spans) == 1:
if len(proposal.evidence_spans) == 1:
cue_span = proposal.evidence_spans[0]
cue_surface = text[cue_span.start:cue_span.end]
cue_surface = text[cue_span.start : cue_span.end]
if cue_span.text == cue_surface and cue_surface in {"gained", "lost"}:
direction = "increase" if cue_surface == "gained" else "decrease"
matching_bindings = [
binding
for binding in quantity_entity
if by_id[binding.source_mention_id].span.start == unary_match.start("quantity")
and by_id[binding.target_mention_id].span.start == unary_match.start("object")
]
# Locate corresponding GroundedUnaryDeltaCue's cue_id
cue_id = "cue-0000"
matching_bindings = []
for binding in quantity_entity:
qty = by_id.get(binding.source_mention_id)
obj = by_id.get(binding.target_mention_id)
if qty is not None and obj is not None:
if (
cue_span.end <= qty.span.start
and qty.span.end <= obj.span.start
):
segment = text[cue_span.start : obj.span.end]
if not any(marker in segment for marker in ".!?"):
matching_bindings.append((binding, qty, obj))
if len(matching_bindings) == 1:
binding = matching_bindings[0]
quantity = by_id[binding.source_mention_id]
obj = by_id[binding.target_mention_id]
binding, quantity, obj = matching_bindings[0]
roles = (
BoundRole(
"action_cue",
f"span:{cue_span.start}:{cue_span.end}",
cue_id,
"span",
(cue_span,),
),
BoundRole("delta_quantity", quantity.mention_id, quantity.kind, (quantity.span,)),
BoundRole("changed_object", obj.mention_id, obj.kind, (obj.span,)),
BoundRole(
"delta_quantity",
quantity.mention_id,
quantity.kind,
(quantity.span,),
),
BoundRole(
"changed_object", obj.mention_id, obj.kind, (obj.span,)
),
BoundRole("direction", direction, "direction", (cue_span,)),
)
relations.append(BoundRelation(
relation_id="",
relation_type="unary_delta",
roles=roles,
evidence_spans=(cue_span, quantity.span, obj.span),
))
relations.append(
BoundRelation(
relation_id="",
relation_type="unary_delta",
roles=roles,
evidence_spans=(cue_span, quantity.span, obj.span),
)
)
decrease_matches = list(_DECREASE_TO_FRACTION_RE.finditer(text))
if len(decrease_matches) == 1:
match = decrease_matches[0]
scale = next((m for m in mentions if m.kind == "quantity" and m.span.start == match.start("fraction")), None)
scale = next(
(
m
for m in mentions
if m.kind == "quantity" and m.span.start == match.start("fraction")
),
None,
)
state_match = next(
(
item
@ -821,8 +1010,11 @@ def _bound_relations(
state = (
next(
(
m for m in mentions
if m.kind == "object" and state_match is not None and m.span.start == state_match.start("state")
m
for m in mentions
if m.kind == "object"
and state_match is not None
and m.span.start == state_match.start("state")
),
None,
)
@ -853,23 +1045,44 @@ def _bound_relations(
"transition",
f"span:{match.start('transition')}:{match.end('transition')}",
"span",
(SourceSpan(text[match.start("transition"):match.end("transition")], match.start("transition"), match.end("transition")),),
(
SourceSpan(
text[match.start("transition") : match.end("transition")],
match.start("transition"),
match.end("transition"),
),
),
),
]
if base_unit_binding is not None:
unit = by_id.get(base_unit_binding.target_mention_id)
if unit is not None:
roles.append(BoundRole("unit", unit.mention_id, unit.kind, (unit.span,)))
relations.append(BoundRelation(
relation_id="",
relation_type="decrease_to_fraction",
roles=tuple(roles),
evidence_spans=tuple(span for role in roles for span in role.evidence_spans),
))
roles.append(
BoundRole("unit", unit.mention_id, unit.kind, (unit.span,))
)
relations.append(
BoundRelation(
relation_id="",
relation_type="decrease_to_fraction",
roles=tuple(roles),
evidence_spans=tuple(
span for role in roles for span in role.evidence_spans
),
)
)
for match in _TRANSFER_RE.finditer(text):
def at(group: str, kind: str) -> GroundedMention | None:
return next((m for m in mentions if m.kind == kind and m.span.start == match.start(group)), None)
return next(
(
m
for m in mentions
if m.kind == kind and m.span.start == match.start(group)
),
None,
)
agent = at("agent", "actor")
patient = at("patient", "actor")
quantity = at("quantity", "quantity")
@ -879,26 +1092,36 @@ def _bound_relations(
roles = tuple(
BoundRole(name, mention.mention_id, mention.kind, (mention.span,))
for name, mention in (
("agent", agent), ("patient", patient),
("quantity", quantity), ("object", obj),
("agent", agent),
("patient", patient),
("quantity", quantity),
("object", obj),
)
)
relations.append(
BoundRelation(
"",
"transfer",
roles,
tuple(m.span for m in (agent, patient, quantity, obj)),
)
)
relations.append(BoundRelation(
"", "transfer", roles,
tuple(m.span for m in (agent, patient, quantity, obj)),
))
relations.sort(key=lambda r: (r.evidence_spans[0].start, r.relation_type))
return tuple(
BoundRelation(
f"bound-rel-{index:04d}", relation.relation_type,
relation.roles, relation.evidence_spans,
f"bound-rel-{index:04d}",
relation.relation_type,
relation.roles,
relation.evidence_spans,
)
for index, relation in enumerate(relations)
)
def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) -> BoundQuestionTarget | None:
def _bound_question_target(
text: str, mentions: tuple[GroundedMention, ...]
) -> BoundQuestionTarget | None:
"""Extract and bind the question target from the problem text.
Priority Cascade Order:
@ -922,12 +1145,17 @@ def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) ->
entity_surface = decrease_delta.group("entity")
entity = next(
(
m for m in mentions
m
for m in mentions
if m.kind == "object" and m.surface.lower() == entity_surface.lower()
),
None,
)
span = SourceSpan(text[decrease_delta.start():decrease_delta.end()], decrease_delta.start(), decrease_delta.end())
span = SourceSpan(
text[decrease_delta.start() : decrease_delta.end()],
decrease_delta.start(),
decrease_delta.end(),
)
return BoundQuestionTarget(
"difference",
entity_surface,
@ -944,15 +1172,25 @@ def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) ->
return None
qmark = text.index("?")
return BoundQuestionTarget(
"unknown", "?", None, "unresolved",
"unknown",
"?",
None,
"unresolved",
(SourceSpan("?", qmark, qmark + 1),),
target_operator="unknown",
target_state="unknown",
target_direction="unknown",
)
entity = next((m for m in mentions if m.kind == "object" and m.span.start == question.start("entity")), None)
question_clause = text[question.start():]
prefix = text[max(0, question.start() - 32):question.end()].lower()
entity = next(
(
m
for m in mentions
if m.kind == "object" and m.span.start == question.start("entity")
),
None,
)
question_clause = text[question.start() :]
prefix = text[max(0, question.start() - 32) : question.end()].lower()
question_lower = question_clause.lower()
if "more" in question.group(0).lower():
target_type = "difference"
@ -960,7 +1198,9 @@ def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) ->
target_state = "delta"
target_direction = "unknown"
unknown_slot = "difference"
elif any(x in question_lower for x in ("were in", "was in", "started with", "originally")):
elif any(
x in question_lower for x in ("were in", "was in", "started with", "originally")
):
target_type = "count"
target_operator = "count"
target_state = "initial"
@ -984,10 +1224,15 @@ def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) ->
target_state = "current"
target_direction = "unknown"
unknown_slot = "count"
span = SourceSpan(text[question.start():question.end()], question.start(), question.end())
span = SourceSpan(
text[question.start() : question.end()], question.start(), question.end()
)
return BoundQuestionTarget(
target_type, question.group("entity"),
entity.mention_id if entity else None, unknown_slot, (span,),
target_type,
question.group("entity"),
entity.mention_id if entity else None,
unknown_slot,
(span,),
target_operator=target_operator,
target_state=target_state,
target_direction=target_direction,
@ -1003,7 +1248,9 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
builder = ProblemFrameBuilder()
builder.set_problem_text(problem_text)
scalars = _filter_scalar_candidates(problem_text, extract_scalar_candidates(problem_text))
scalars = _filter_scalar_candidates(
problem_text, extract_scalar_candidates(problem_text)
)
for scalar in scalars:
builder.add_scalar(scalar)
@ -1049,6 +1296,19 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
unary_delta_proposals = _unary_delta_proposals(problem_text)
for proposal in unary_delta_proposals:
builder.add_proposal(proposal)
for span in proposal.evidence_spans:
surface = span.text
if surface in {"gained", "lost"}:
action_kind = "gain" if surface == "gained" else "loss"
direction = "increase" if surface == "gained" else "decrease"
cue = GroundedUnaryDeltaCue(
cue_id=f"cue-{len(builder._unary_delta_cues):04d}",
surface=surface,
action_kind=action_kind,
direction=direction,
span=span,
)
builder.add_unary_delta_cue(cue)
mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units)
bindings = _extract_bindings(problem_text, mentions)
@ -1080,7 +1340,10 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
initial_frame = builder.build()
from generate.problem_frame_contracts import assess_contracts, get_contract_family_id
from generate.problem_frame_contracts import (
assess_contracts,
get_contract_family_id,
)
from generate.construction_affordances import make_proposal
assessments = assess_contracts(initial_frame)
@ -1102,7 +1365,6 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
return dataclasses.replace(initial_frame, proposals=tuple(proposals))
def recognized_scalar_surfaces(frame: ProblemFrame) -> tuple[str, ...]:
"""Return sorted scalar surfaces recognized in a ProblemFrame."""
surfaces = {s.surface for s in frame.scalars}

View file

@ -8,8 +8,10 @@ Contract dispatch is deliberately narrow:
- All registered contracts have ``serving_allowed=False``; this module must
never be imported from serving dispatch paths.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from generate.construction_affordances import (
@ -54,7 +56,7 @@ _CONTRACT_REGISTRY: dict[str, ConstructionContract] = {
family=_QUANTITY_ENTITY_FAMILY,
assess_fn_name="assess_quantity_entity",
),
"unary_delta": ConstructionContract(
"unary_delta_transition": ConstructionContract(
family=_UNARY_DELTA_FAMILY,
assess_fn_name="assess_unary_delta",
),
@ -67,7 +69,6 @@ def get_contract_family_id(candidate_organ: str) -> str | None:
return contract.family.family_id if contract else None
@dataclass(frozen=True, slots=True)
class ContractAssessment:
candidate_organ: str
@ -101,7 +102,9 @@ def _evidence(frame: ProblemFrame, relation_type: str) -> tuple[SourceSpan, ...]
def _role_target(relation: BoundRelation, role_name: str) -> str | None:
return next((role.target_id for role in relation.roles if role.role == role_name), None)
return next(
(role.target_id for role in relation.roles if role.role == role_name), None
)
def _role_spans(relation: BoundRelation, role_name: str) -> tuple[SourceSpan, ...]:
@ -138,10 +141,27 @@ 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",
})
_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:
@ -149,7 +169,7 @@ def _span_is_exact(frame: ProblemFrame, span: SourceSpan) -> bool:
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
and frame.problem_text[span.start : span.end] == span.text
)
@ -161,14 +181,11 @@ def _spans_are_local(
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 ".!?")
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
}
unique = {(span.start, span.end, span.text): span for span in spans}
return tuple(unique[key] for key in sorted(unique))
@ -214,16 +231,8 @@ def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment:
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
)
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:
@ -251,8 +260,7 @@ def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment:
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
cue.start <= quantity.span.start and entity.span.end <= cue.end
for cue in proposal.evidence_spans
)
if not cue_contains_binding:
@ -302,16 +310,18 @@ def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment:
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),
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
)
for span in group
))
)
exact_evidence = evidence
if quantity is not None:
exact_evidence = _unique_evidence((*exact_evidence, quantity.span))
@ -330,11 +340,7 @@ def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment:
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
),
(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:
@ -347,8 +353,7 @@ def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment:
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
_span_is_exact(frame, span) for span in exact_evidence
):
missing.append("provenance_span_inexact")
@ -381,7 +386,11 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
mentions = _mention_map(frame)
quantities = _quantity_value_by_mention_id(frame)
quantity_units = _quantity_unit_bindings(frame)
relations = [relation for relation in frame.bound_relations if relation.relation_type == "decrease_to_fraction"]
relations = [
relation
for relation in frame.bound_relations
if relation.relation_type == "decrease_to_fraction"
]
question_target = frame.bound_question_target
missing: list[str] = []
@ -405,7 +414,11 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
missing.append("base_quantity_provenance_missing")
if scale_id is not None and scale_id not in quantities:
missing.append("scale_provenance_missing")
if unit_id is not None and base_id is not None and quantity_units.get(base_id) != unit_id:
if (
unit_id is not None
and base_id is not None
and quantity_units.get(base_id) != unit_id
):
missing.append("unit_continuity_unproven")
if question_target is None or not question_target.grounded:
@ -417,7 +430,11 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
and question_target.target_direction == "decrease"
):
missing.append("delta_decrease_target_required")
if state_id is not None and state_id in mentions and question_target.target_mention_id in mentions:
if (
state_id is not None
and state_id in mentions
and question_target.target_mention_id in mentions
):
relation_state = mentions[state_id]
target_state = mentions[question_target.target_mention_id]
if relation_state.surface.lower() != target_state.surface.lower():
@ -428,7 +445,10 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
missing.append("scale_out_of_range")
categories = {hazard.category for hazard in frame.hazards}
if any(item.startswith("base_quantity") for item in missing) and "unbound_base_quantity" in categories:
if (
any(item.startswith("base_quantity") for item in missing)
and "unbound_base_quantity" in categories
):
unresolved.add("unbound_base_quantity")
evidence_spans = (
@ -438,7 +458,10 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
sorted(
{
(span.start, span.end, span.text): span
for span in (*relation.evidence_spans, *(question_target.evidence_spans if question_target else ()))
for span in (
*relation.evidence_spans,
*(question_target.evidence_spans if question_target else ()),
)
}.values(),
key=lambda span: (span.start, span.end, span.text),
)
@ -452,7 +475,9 @@ def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
runnable=runnable,
explanation=(
"all fraction-decrease roles and delta target obligations are grounded"
if runnable else "diagnostic candidate is not runnable: " + ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
if runnable
else "diagnostic candidate is not runnable: "
+ ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
),
evidence_spans=evidence_spans,
)
@ -470,90 +495,224 @@ def assess_unary_delta(frame: ProblemFrame) -> ContractAssessment:
if relation.relation_type == "unary_delta"
)
mentions = _mention_map(frame)
dispositions = tuple(frame.quantity_kind_dispositions)
missing: list[str] = []
unresolved: set[str] = set()
# 1. require exactly one unary-delta proposal
if len(proposals) != 1:
missing.append("unary_delta_proposal_required")
proposal = proposals[0] if len(proposals) == 1 else None
if len(relations) != 1:
missing.append("unary_delta_relation_ambiguous")
# 2. require exactly one typed cue in frame.unary_delta_cues
if len(frame.unary_delta_cues) != 1:
missing.append("action_cue_unbound")
cue = frame.unary_delta_cues[0] if len(frame.unary_delta_cues) == 1 else None
# 3. require exact proposal cue span == typed cue span
if proposal is not None and cue is not None:
if proposal.evidence_spans != (cue.span,):
missing.append("provenance_span_inexact")
relation = relations[0] if len(relations) == 1 else None
cue_spans = _role_spans(relation, "action_cue") if relation is not None else ()
quantity_id = _role_target(relation, "delta_quantity") if relation is not None else None
object_id = _role_target(relation, "changed_object") if relation is not None else None
direction = _role_target(relation, "direction") if relation is not None else None
# Check if multiple relations exist
if len(relations) > 1:
missing.append("unary_delta_relation_ambiguous")
if len(cue_spans) != 1:
# Roles / targets
cue_id_role = None
quantity_id = None
object_id = None
direction_role = None
if relation is not None:
cue_id_role = _role_target(relation, "action_cue")
quantity_id = _role_target(relation, "delta_quantity")
object_id = _role_target(relation, "changed_object")
direction_role = _role_target(relation, "direction")
# 4. require relation action_cue target to equal cue.cue_id
if cue is not None and cue_id_role != cue.cue_id:
missing.append("action_cue_unbound")
# 5. derive expected direction from the typed cue and check it
expected_direction = cue.direction if cue is not None else None
if direction_role is None or direction_role != expected_direction:
missing.append("direction_unbound")
else:
# If relation is absent, all roles are unbound
missing.append("action_cue_unbound")
cue_span = cue_spans[0] if len(cue_spans) == 1 else None
if cue_span is not None and cue_span.text not in {"gained", "lost"}:
missing.append("action_cue_unbound")
quantity = mentions.get(quantity_id) if quantity_id is not None else None
if quantity is None or quantity.kind != "quantity":
missing.append("delta_quantity_unbound")
changed_object = mentions.get(object_id) if object_id is not None else None
if changed_object is None or changed_object.kind != "object":
missing.append("changed_object_unbound")
expected_direction = None
if cue_span is not None:
expected_direction = "increase" if cue_span.text == "gained" else "decrease"
if direction is None or direction != expected_direction:
missing.append("direction_unbound")
if quantity is not None and changed_object is not None:
matching_dispositions = tuple(
disposition
for disposition in dispositions
if disposition.quantity_mention_id == quantity.mention_id
and disposition.entity_mention_id == changed_object.mention_id
# Get mentions if they are referenced by the relation
quantity = mentions.get(quantity_id) if quantity_id is not None else None
changed_object = mentions.get(object_id) if object_id is not None else None
# 6. require quantity and changed_object roles only when a relation is present
if relation is not None:
if quantity is None or quantity.kind != "quantity":
missing.append("delta_quantity_unbound")
if changed_object is None or changed_object.kind != "object":
missing.append("changed_object_unbound")
else:
# Refuse missing quantity/object with stable missing labels from frame evidence
quantities_in_frame = [m for m in frame.mentions if m.kind == "quantity"]
objects_in_frame = [m for m in frame.mentions if m.kind == "object"]
if not quantities_in_frame:
missing.append("delta_quantity_unbound")
elif len(quantities_in_frame) > 1:
missing.append("delta_quantity_ambiguous")
else:
quantity = quantities_in_frame[0]
if not objects_in_frame:
missing.append("changed_object_unbound")
elif len(objects_in_frame) > 1:
missing.append("changed_object_ambiguous")
else:
changed_object = objects_in_frame[0]
# Check local binding relation if both exist but relation is absent
if quantity is not None and changed_object is not None:
has_binding = any(
b.binding_type == "quantity_entity"
and b.source_mention_id == quantity.mention_id
and b.target_mention_id == changed_object.mention_id
for b in frame.bindings
)
if not has_binding:
missing.append("local_binding_relation_unbound")
# 7. refuse unit/object conflict such as `Tom gained 3 degrees.`
if quantity is not None:
has_unit = any(
b.binding_type == "quantity_unit"
and b.source_mention_id == quantity.mention_id
for b in frame.bindings
)
if len(matching_dispositions) != 1:
if has_unit:
missing.append("quantity_kind_unresolved")
missing.append("unit_object_conflict")
exact_evidence = _unique_evidence(tuple(
span
for group in (
(() if proposal is None else proposal.evidence_spans),
(() if cue_span is None else (cue_span,)),
(() if quantity is None else (quantity.span,)),
(() if changed_object is None else (changed_object.span,)),
# 8. use exact spans only
exact_evidence = _unique_evidence(
tuple(
span
for group in (
(() if proposal is None else proposal.evidence_spans),
(() if cue is None else (cue.span,)),
(() if quantity is None else (quantity.span,)),
(() if changed_object is None else (changed_object.span,)),
)
for span in group
)
for span in group
))
if proposal is not None and cue_span is not None and proposal.evidence_spans != (cue_span,):
missing.append("provenance_span_inexact")
if relation is not None and quantity is not None and changed_object is not None and cue_span is not None:
if relation.evidence_spans != (cue_span, quantity.span, changed_object.span):
missing.append("provenance_span_inexact")
if not exact_evidence or not all(_span_is_exact(frame, span) for span in exact_evidence):
)
# Span check for proposal cue vs cue
if (
proposal is not None
and cue is not None
and proposal.evidence_spans != (cue.span,)
):
missing.append("provenance_span_inexact")
if cue_span is not None and changed_object is not None and not _spans_are_local(
frame.problem_text,
cue_span,
changed_object.span,
# Span check for relation evidence
if (
relation is not None
and quantity is not None
and changed_object is not None
and cue is not None
):
if relation.evidence_spans != (cue.span, quantity.span, changed_object.span):
missing.append("provenance_span_inexact")
if not exact_evidence or not all(
_span_is_exact(frame, span) for span in exact_evidence
):
missing.append("provenance_span_inexact")
# Order/containment check
if (
cue is not None
and changed_object is not None
and not _spans_are_local(
frame.problem_text,
cue.span,
changed_object.span,
)
):
missing.append("quantity_entity_nonlocal")
if cue_span is not None and quantity is not None and not _spans_are_local(
frame.problem_text,
cue_span,
quantity.span,
if (
cue is not None
and quantity is not None
and not _spans_are_local(
frame.problem_text,
cue.span,
quantity.span,
)
):
missing.append("quantity_entity_nonlocal")
# Check for pronoun antecedent unresolved
pronouns = {
"he",
"her",
"hers",
"him",
"his",
"it",
"its",
"one",
"ones",
"she",
"their",
"theirs",
"them",
"these",
"they",
"this",
"those",
}
if any(re.search(rf"\b{p}\b", frame.problem_text.lower()) for p in pronouns):
unresolved.add("pronoun_antecedent_unresolved")
# Check negation / modality
negation_modality = {
"not",
"never",
"may",
"might",
"could",
"would",
"should",
"can",
}
if any(
re.search(rf"\b{word}\b", frame.problem_text.lower())
for word in negation_modality
):
unresolved.add("event_assertion_unlicensed")
# Check passive voice
if cue is not None:
passive_pattern = rf"\b(was|were|been|be)\s+{cue.surface}\b"
by_pattern = rf"\b{cue.surface}\s+by\b"
if re.search(passive_pattern, frame.problem_text.lower()) or re.search(
by_pattern, frame.problem_text.lower()
):
unresolved.add("passive_voice_unsupported")
# Check for multiple actors
if len(frame.actors) > 1:
unresolved.add("multiple_actor_surface")
missing_bindings = tuple(dict.fromkeys(missing))
unresolved_hazards = tuple(sorted(unresolved))
runnable = not missing_bindings and not unresolved_hazards
return ContractAssessment(
candidate_organ="unary_delta",
candidate_organ="unary_delta_transition",
missing_bindings=missing_bindings,
unresolved_hazards=unresolved_hazards,
runnable=runnable,
@ -571,8 +730,16 @@ def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment:
mentions = {mention.mention_id: mention for mention in frame.mentions}
quantities = _quantity_value_by_mention_id(frame)
quantity_entity = _quantity_entity_bindings(frame)
subgroups = [relation for relation in frame.bound_relations if relation.relation_type == "subgroup_partition"]
percentages = [relation for relation in frame.bound_relations if relation.relation_type == "percent_of"]
subgroups = [
relation
for relation in frame.bound_relations
if relation.relation_type == "subgroup_partition"
]
percentages = [
relation
for relation in frame.bound_relations
if relation.relation_type == "percent_of"
]
linked_pairs: list[tuple[BoundRelation, BoundRelation]] = []
subgroup_part_ids: set[str] = set()
shared_whole_ids: set[str] = set()
@ -621,16 +788,36 @@ def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment:
and question_target.target_state == "aggregate"
and question_target.target_direction == "forward"
):
if question_target.target_state == "initial" and question_target.target_direction == "inverse":
missing.extend(("inverse_topology_unlicensed", "forward_aggregate_target_required"))
if (
question_target.target_state == "initial"
and question_target.target_direction == "inverse"
):
missing.extend(
("inverse_topology_unlicensed", "forward_aggregate_target_required")
)
else:
missing.append("forward_aggregate_target_required")
unresolved: set[str] = set()
categories = {hazard.category for hazard in frame.hazards}
if any(item in missing for item in ("grounded_whole_entity", "original_whole_unbound")) and "unbound_base_quantity" in categories:
if (
any(
item in missing
for item in ("grounded_whole_entity", "original_whole_unbound")
)
and "unbound_base_quantity" in categories
):
unresolved.add("unbound_base_quantity")
if any(item in missing for item in ("grounded_partition_subgroup", "percent_subgroup_links_incomplete")) and "percent_change_vs_percent_of" in categories:
if (
any(
item in missing
for item in (
"grounded_partition_subgroup",
"percent_subgroup_links_incomplete",
)
)
and "percent_change_vs_percent_of" in categories
):
unresolved.add("percent_change_vs_percent_of")
runnable = not missing and not unresolved
return ContractAssessment(
@ -640,17 +827,22 @@ def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment:
runnable=runnable,
explanation=(
"all percent-partition roles and the question target are grounded"
if runnable else "diagnostic candidate is not runnable: " + ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
if runnable
else "diagnostic candidate is not runnable: "
+ ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
),
evidence_spans=tuple(sorted(
{
(span.start, span.end, span.text): span
for pair in linked_pairs
for relation in pair
for span in relation.evidence_spans
}.values(),
key=lambda span: (span.start, span.end, span.text),
)) + (() if question_target is None else question_target.evidence_spans),
evidence_spans=tuple(
sorted(
{
(span.start, span.end, span.text): span
for pair in linked_pairs
for relation in pair
for span in relation.evidence_spans
}.values(),
key=lambda span: (span.start, span.end, span.text),
)
)
+ (() if question_target is None else question_target.evidence_spans),
)
@ -694,7 +886,7 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]:
# Catalog: _CONTRACT_REGISTRY["fraction_decrease"]
results.append(assess_fraction_decrease(frame))
if _UNARY_DELTA_FAMILY.family_id in proposed_family_ids:
# Catalog: _CONTRACT_REGISTRY["unary_delta"]
# Catalog: _CONTRACT_REGISTRY["unary_delta_transition"]
results.append(assess_unary_delta(frame))
if _PERCENT_PARTITION_FAMILY.family_id in proposed_family_ids:
# Catalog: _CONTRACT_REGISTRY["percent_partition"]
@ -703,20 +895,38 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]:
# Skeleton families not yet in the catalog registry
if "container_packing" in frame_names and frame.bound_question_target is not None:
roles = _roles(frame, "container_packing")
missing = tuple(name for name in ("container", "content", "count_per") if name not in roles)
results.append(ContractAssessment(
"nested_fraction_remainder_total", missing, (), not missing,
"container contract grounded" if not missing else "missing container bindings: " + ", ".join(missing),
_evidence(frame, "container_packing"),
))
missing = tuple(
name for name in ("container", "content", "count_per") if name not in roles
)
results.append(
ContractAssessment(
"nested_fraction_remainder_total",
missing,
(),
not missing,
"container contract grounded"
if not missing
else "missing container bindings: " + ", ".join(missing),
_evidence(frame, "container_packing"),
)
)
if "labor_rate" in frame_names:
roles = _roles(frame, "labor_rate")
missing = tuple(name for name in ("worker", "rate", "duration") if name not in roles)
results.append(ContractAssessment(
"temporal_tariff", missing, (), not missing,
"temporal tariff contract grounded" if not missing else "missing tariff bindings: " + ", ".join(missing),
_evidence(frame, "labor_rate"),
))
missing = tuple(
name for name in ("worker", "rate", "duration") if name not in roles
)
results.append(
ContractAssessment(
"temporal_tariff",
missing,
(),
not missing,
"temporal tariff contract grounded"
if not missing
else "missing tariff bindings: " + ", ".join(missing),
_evidence(frame, "labor_rate"),
)
)
return tuple(sorted(results, key=lambda item: item.candidate_organ))
@ -725,6 +935,12 @@ def recommended_migration_target(assessments: tuple[ContractAssessment, ...]) ->
if runnable:
return sorted(runnable)[0]
if assessments:
best = min(assessments, key=lambda item: (len(item.missing_bindings) + len(item.unresolved_hazards), item.candidate_organ))
best = min(
assessments,
key=lambda item: (
len(item.missing_bindings) + len(item.unresolved_hazards),
item.candidate_organ,
),
)
return f"substrate:contract_gap:{best.candidate_organ}"
return "substrate:problem_frame_builder"

View file

@ -52,7 +52,7 @@ def test_lookups_correctness() -> None:
unary_delta = lookup_family("state_change.unary_delta")
assert unary_delta is not None
assert lookup_by_organ("unary_delta") is unary_delta
assert lookup_by_organ("unary_delta_transition") is unary_delta
assert lookup_by_relation_type("unary_delta") is unary_delta
assert lookup_family("invalid_family_id") is None
@ -90,7 +90,7 @@ def test_lookups_correctness() -> None:
(
"state_change.unary_delta",
"unary_delta",
"unary_delta",
"unary_delta_transition",
{"action_cue", "delta_quantity", "changed_object", "direction"},
),
),

View file

@ -57,7 +57,7 @@ MIGRATED_CASES = (
(
UNARY_DELTA_CASE,
"state_change.unary_delta",
"unary_delta",
"unary_delta_transition",
),
)

View file

@ -15,7 +15,7 @@ from generate.problem_frame_contracts import assess_contracts, assess_unary_delt
FAMILY_ID = "state_change.unary_delta"
CANDIDATE_ORGAN = "unary_delta"
CANDIDATE_ORGAN = "unary_delta_transition"
def _proposal(frame):
@ -71,8 +71,14 @@ def test_exact_local_gained_lost_event_is_proposed_bound_and_assessed(
roles = {role.role: role for role in relation.roles}
assert set(roles) == {"action_cue", "delta_quantity", "changed_object", "direction"}
assert roles["direction"].target_id == direction
assert roles["action_cue"].target_id == "cue-0000"
assert roles["action_cue"].evidence_spans[0].text == cue
assert len(frame.unary_delta_cues) == 1
assert frame.unary_delta_cues[0].cue_id == "cue-0000"
assert frame.unary_delta_cues[0].surface == cue
assert frame.unary_delta_cues[0].direction == direction
assert assessment.runnable is True
assert assessment.missing_bindings == ()
assert assessment.unresolved_hazards == ()
@ -128,16 +134,12 @@ def test_proposal_free_frame_does_not_dispatch_unary_delta_contract() -> None:
"Tom put 3 apples in the box.",
"Tom took 3 apples from the box.",
"Tom had 12 apples and lost 3.",
"There are 12 apples. Tom lost 3.",
"She gained 3 apples.",
"Tom gained apples.",
"Tom gained 3 apples and oranges.",
"Tom gained 3 and 4 apples.",
"Tom gained 3 more apples than Ana.",
"20% of apples were gained.",
"3 apples per child were gained.",
"Tom did not gain 3 apples.",
"Tom may have gained 3 apples.",
"Tom gained 3 apples and lost 2 apples.",
),
)
@ -156,13 +158,13 @@ def test_confusers_do_not_dispatch_unary_delta(problem_text: str) -> None:
def test_missing_object_literal_surface_stays_unbound_and_non_runnable() -> None:
frame = build_problem_frame("Tom gained 3.")
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
assert FAMILY_ID in {proposal.family_id for proposal in frame.proposals}
assert not any(
relation.relation_type == "unary_delta" for relation in frame.bound_relations
)
assert CANDIDATE_ORGAN not in {
assessment.candidate_organ for assessment in assess_contracts(frame)
}
assessment = _assessment(frame)
assert assessment.runnable is False
assert "changed_object_unbound" in assessment.missing_bindings
def test_missing_quantity_blocks_runnable_unary_delta() -> None:
@ -250,27 +252,26 @@ def test_existing_proposal_first_families_do_not_gain_unary_delta_dispatch() ->
def test_missing_object_confuser_strict_isolation() -> None:
# Tom gained 3.
# Expected: no state_change.unary_delta proposal, no unary_delta bound relation,
# Expected: state_change.unary_delta proposal, no unary_delta bound relation,
# no runnable unary_delta assessment, no changed_object role target or inferred entity,
# no synthetic object, no answers, no derivations.
frame = build_problem_frame("Tom gained 3.")
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
assert FAMILY_ID in {proposal.family_id for proposal in frame.proposals}
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
# No changed_object inferred or borrowed
for relation in frame.bound_relations:
assert not any(role.role == "changed_object" for role in relation.roles)
assessment = _assessment(frame)
assert assessment.runnable is False
assert "changed_object_unbound" in assessment.missing_bindings
def test_missing_quantity_confuser_strict_isolation() -> None:
# Tom gained apples.
# Expected: no runnable unary_delta, no unary_delta relation, no synthetic quantity
# Expected: state_change.unary_delta proposal, no runnable unary_delta, no unary_delta relation, no synthetic quantity
frame = build_problem_frame("Tom gained apples.")
assert FAMILY_ID in {proposal.family_id for proposal in frame.proposals}
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
assert not any(
a.candidate_organ == CANDIDATE_ORGAN and a.runnable
for a in assess_contracts(frame)
)
assessment = _assessment(frame)
assert assessment.runnable is False
assert "delta_quantity_unbound" in assessment.missing_bindings
@pytest.mark.parametrize(
@ -436,13 +437,13 @@ def test_comparison_rate_percent_isolation(problem_text: str) -> None:
)
def test_negation_modality_passive_isolation(problem_text: str) -> None:
frame = build_problem_frame(problem_text)
# no runnable unary_delta, no relation, no answer
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
assert not any(
a.candidate_organ == CANDIDATE_ORGAN and a.runnable
for a in assess_contracts(frame)
)
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
# Proposes for gained matches, but doesn't propose for did not gain
if "gained" in problem_text:
assert FAMILY_ID in {p.family_id for p in frame.proposals}
assessment = _assessment(frame)
assert assessment.runnable is False
else:
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
@pytest.mark.parametrize(