core/generate/problem_frame_bound_relations.py
Shay c6263f5a91
refactor(kernel): split ProblemFrame builder phases (#919)
* refactor(kernel): add ProblemFrame extraction phase module

* refactor(kernel): add ProblemFrame proposal phase module

* refactor(kernel): add ProblemFrame mention phase module

* refactor(kernel): add ProblemFrame bound relation phase module

* refactor(kernel): reduce ProblemFrame builder to phase orchestration

* test(kernel): pin ProblemFrame phase boundaries

* test(kernel): keep unary delta smoke within supported slice
2026-06-25 16:16:04 -07:00

574 lines
21 KiB
Python

"""ProblemFrame bound relation and target helpers.
This module owns the phase that turns grounded mentions, bindings, proposals, and
unary-delta cues into quantity-kind dispositions, ``BoundRelation`` records, and
bound question targets. It does not assess contracts or create proposals.
"""
from __future__ import annotations
import re
from generate.construction_affordances import ConstructionProposal
from generate.kernel_facts import (
BoundRelation,
BoundRole,
GroundedMention,
MentionBinding,
SourceSpan,
)
from generate.problem_frame import (
BoundQuestionTarget,
GroundedUnaryDeltaCue,
QuantityKindDisposition,
)
from generate.problem_frame_extractors import _sentence_contains_current_or_now
_QUESTION_ENTITY_RE = re.compile(
r"\bhow\s+(?:many|much)\s+(?:more\s+)?(?P<entity>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
_DECREASE_STATE_RE = re.compile(
r"(?P<state>[A-Za-z][A-Za-z'-]*)\s+will\s+decrease\s+to",
re.IGNORECASE,
)
_DECREASE_DELTA_QUESTION_RE = re.compile(
r"\bwhat\s+will\s+the\s+(?P<entity>[A-Za-z][A-Za-z'-]*)\s+decrease\s+by\??",
re.IGNORECASE,
)
# Duplicated intentionally to preserve phase-local ownership.
# Do not import another phase's internals just to share this regex.
_COPULAR_PARTITION_RE = re.compile(
r"\b(?P<quantity>half|third|quarter)\b\s+of\s+(?:the\s+)?"
r"(?P<whole>[A-Za-z][A-Za-z'-]*)\s+(?:are|is)\s+(?P<part>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
# Duplicated intentionally to preserve phase-local ownership.
# Do not import another phase's internals just to share this regex.
_DECREASE_TO_FRACTION_RE = re.compile(
r"(?P<transition>decrease\s+to)\s+(?P<fraction>\d+\s*/\s*\d+)\s+of",
re.IGNORECASE,
)
# Duplicated intentionally to preserve phase-local ownership.
# Do not import another phase's internals just to share this regex.
_TRANSFER_RE = re.compile(
r"\b(?P<agent>[A-Z][A-Za-z'-]*)\s+(?:gave|gives|give|handed|passed)\s+"
r"(?P<patient>[A-Z][A-Za-z'-]*)\s+"
r"(?P<quantity>\d+(?:\.\d+)?)\s+(?P<object>[A-Za-z][A-Za-z'-]*)",
)
def _quantity_kind_dispositions(
text: str,
mentions: tuple[GroundedMention, ...],
bindings: tuple[MentionBinding, ...],
proposals: tuple[ConstructionProposal, ...],
) -> tuple[QuantityKindDisposition, ...]:
"""Close kind only for the exact proposal-backed local binding."""
quantity_entity_proposals = tuple(
proposal
for proposal in proposals
if proposal.family_id == "binding.quantity_entity"
)
if len(quantity_entity_proposals) != 1:
return ()
quantity_entity_proposal = quantity_entity_proposals[0]
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
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,
)
)
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, ...],
bindings: tuple[MentionBinding, ...],
proposals: tuple[ConstructionProposal, ...],
unary_delta_cues: tuple[GroundedUnaryDeltaCue, ...],
) -> tuple[BoundRelation, ...]:
by_id = {m.mention_id: m for m in mentions}
relations: list[BoundRelation] = []
quantity_entity = [b for b in bindings if b.binding_type == "quantity_entity"]
whole = next(
(
binding
for binding in quantity_entity
if "%" not in by_id[binding.source_mention_id].surface
and by_id[binding.source_mention_id].surface.lower()
not in {"half", "third", "quarter"}
),
None,
)
for binding in quantity_entity:
quantity = by_id[binding.source_mention_id]
part = by_id[binding.target_mention_id]
canonical_part = min(
(
mention
for mention in mentions
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",
}:
continue
roles = [
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
),
)
)
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,
)
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()
),
key=lambda mention: mention.span.start,
default=whole,
)
roles = (
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),
)
)
unary_delta_proposals = tuple(
proposal
for proposal in proposals
if proposal.family_id == "state_change.unary_delta"
)
if len(unary_delta_proposals) == 1:
proposal = unary_delta_proposals[0]
if len(proposal.evidence_spans) == 1:
cue_span = proposal.evidence_spans[0]
cue_surface = text[cue_span.start : cue_span.end]
if cue_span.text == cue_surface and cue_surface in {"gained", "lost"}:
direction = "increase" if cue_surface == "gained" else "decrease"
# Locate corresponding GroundedUnaryDeltaCue's cue_id
cue_id = None
for cue in unary_delta_cues:
if cue.span.start == cue_span.start and cue.span.end == cue_span.end:
cue_id = cue.cue_id
break
if cue_id is not None:
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, quantity, obj = matching_bindings[0]
roles = (
BoundRole(
"action_cue",
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("direction", direction, "direction", (cue_span,)),
)
relations.append(
BoundRelation(
relation_id="",
relation_type="unary_delta",
roles=roles,
evidence_spans=(cue_span, quantity.span, obj.span),
)
)
decrease_matches = list(_DECREASE_TO_FRACTION_RE.finditer(text))
if len(decrease_matches) == 1:
match = decrease_matches[0]
scale = next(
(
m
for m in mentions
if m.kind == "quantity" and m.span.start == match.start("fraction")
),
None,
)
state_match = next(
(
item
for item in _DECREASE_STATE_RE.finditer(text)
if item.start("state") < match.start("transition")
),
None,
)
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")
),
None,
)
if state_match is not None
else None
)
unit_binding_by_quantity = {
binding.source_mention_id: binding
for binding in bindings
if binding.binding_type == "quantity_unit"
}
base_candidates = [
mention
for mention in mentions
if mention.kind == "quantity"
and mention.mention_id != (scale.mention_id if scale else None)
and mention.mention_id in unit_binding_by_quantity
and _sentence_contains_current_or_now(text, mention.span.start)
]
if len(base_candidates) == 1 and scale is not None and state is not None:
base = base_candidates[0]
base_unit_binding = unit_binding_by_quantity.get(base.mention_id)
roles = [
BoundRole("base_quantity", base.mention_id, base.kind, (base.span,)),
BoundRole("scale", scale.mention_id, scale.kind, (scale.span,)),
BoundRole("state_entity", state.mention_id, state.kind, (state.span,)),
BoundRole(
"transition",
f"span:{match.start('transition')}:{match.end('transition')}",
"span",
(
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
),
)
)
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,
)
agent = at("agent", "actor")
patient = at("patient", "actor")
quantity = at("quantity", "quantity")
obj = at("object", "object")
if all((agent, patient, quantity, obj)):
assert agent and patient and quantity and obj
roles = tuple(
BoundRole(name, mention.mention_id, mention.kind, (mention.span,))
for name, mention in (
("agent", agent),
("patient", patient),
("quantity", quantity),
("object", 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,
)
for index, relation in enumerate(relations)
)
def _bound_question_target(
text: str, mentions: tuple[GroundedMention, ...]
) -> BoundQuestionTarget | None:
"""Extract and bind the question target from the problem text.
Priority Cascade Order:
1. Specific regex-based triggers:
- Proportional decrease delta: checked first using ``_DECREASE_DELTA_QUESTION_RE``.
If matched, returns a difference/delta/decrease target.
2. General question clause extraction:
- Triggers on ``_QUESTION_ENTITY_RE``.
- If no match, but "?" is present in the text, returns an "unknown" target.
3. Target classification of the question clause:
- "more" -> difference / delta / unknown direction.
- Initial state indicators ("were in", "was in", "started with", "originally") -> count / initial / inverse.
- Remaining indicators ("remaining", "left" in context) -> count / final / remaining.
- Aggregate indicators ("total", "altogether", "own") -> count / aggregate / forward.
- Portion percentage ("percent", "percentage") -> portion / final / forward.
- Portion fraction ("ratio", "fraction") -> portion / final / forward.
- Fallback -> count / final / forward.
"""
decrease_delta = _DECREASE_DELTA_QUESTION_RE.search(text)
if decrease_delta is not None:
entity_surface = decrease_delta.group("entity")
entity = next(
(
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(),
)
return BoundQuestionTarget(
"difference",
entity_surface,
entity.mention_id if entity else None,
"delta_quantity",
(span,),
target_operator="difference",
target_state="delta",
target_direction="decrease",
)
question = _QUESTION_ENTITY_RE.search(text)
if question is None:
if "?" not in text:
return None
qmark = text.index("?")
return BoundQuestionTarget(
"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()
question_lower = question_clause.lower()
if "more" in question.group(0).lower():
target_type = "difference"
target_operator = "difference"
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")
):
target_type = "count"
target_operator = "count"
target_state = "initial"
target_direction = "inverse"
unknown_slot = "initial"
elif any(x in prefix for x in ("remaining", "left")):
target_type = "remaining"
target_operator = "count"
target_state = "final"
target_direction = "remaining"
unknown_slot = "remaining"
elif any(x in question_lower for x in ("total", "altogether", "own")):
target_type = "count"
target_operator = "count"
target_state = "aggregate"
target_direction = "forward"
unknown_slot = "count"
else:
target_type = "count"
target_operator = "count"
target_state = "current"
target_direction = "unknown"
unknown_slot = "count"
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_operator=target_operator,
target_state=target_state,
target_direction=target_direction,
)