core/generate/problem_frame_proposals.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

265 lines
7.1 KiB
Python

"""ProblemFrame construction proposal helpers.
This module owns pre-assessment construction hypotheses. It may create
``ConstructionProposal`` records from exact surface/process evidence, but it does
not bind roles, assess contracts, or serve.
"""
from __future__ import annotations
import re
from generate.construction_affordances import ConstructionProposal, propose_construction
from generate.kernel_facts import GroundedScalar, SourceSpan
from generate.process_frames import ProcessFrame
from generate.problem_frame_extractors import surface_in_text
_DECREASE_TO_FRACTION_RE = re.compile(
r"(?P<transition>decrease\s+to)\s+(?P<fraction>\d+\s*/\s*\d+)\s+of",
re.IGNORECASE,
)
_PERCENT_OF_PROPOSAL_RE = re.compile(
r"\b\d+(?:\.\d+)?\s*%\s+of\b",
re.IGNORECASE,
)
# Duplicated intentionally to preserve phase-local ownership.
# Do not import another phase's internals just to share this regex.
_ENTITY_AFTER_QUANTITY_RE = re.compile(
r"(?P<quantity>\d+(?:\.\d+)?\s*%?)\s+(?:of\s+(?:the\s+)?)?"
r"(?P<entity>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
_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."""
matches = tuple(_DECREASE_TO_FRACTION_RE.finditer(text))
if len(matches) != 1:
return ()
match = matches[0]
evidence = SourceSpan(
text[match.start() : match.end()],
match.start(),
match.end(),
)
return (
propose_construction(
"proportional_change.decrease_to_fraction",
(evidence,),
),
)
def _percent_partition_proposals(
text: str,
frames: tuple[ProcessFrame, ...],
) -> tuple[ConstructionProposal, ...]:
"""Propose percent partition from a process cue plus explicit percent-of."""
frame_names = {frame.name for frame in frames}
if not frame_names & {"partition", "consumption"}:
return ()
evidence_spans = tuple(
SourceSpan(text[match.start() : match.end()], match.start(), match.end())
for match in _PERCENT_OF_PROPOSAL_RE.finditer(text)
)
if not evidence_spans:
return ()
return (
propose_construction(
"partition.percent_partition",
evidence_spans,
),
)
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 _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 _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 _unary_delta_proposals(
text: str,
) -> tuple[ConstructionProposal, ...]:
"""Propose the narrow gained/lost unary-delta slice from exact local cues."""
matches = list(re.finditer(r"\b(gained|lost)\b", text))
if len(matches) != 1:
return ()
match = matches[0]
# 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 ()
# Competing / blocking surfaces
confusers = {
"percent",
"percentage",
"%",
"per",
"each",
"ratio",
"than",
"more than",
"less than",
"fewer than",
"greater than",
"times as",
}
for c in confusers:
pattern = rf"\b{re.escape(c)}\b" if c[0].isalnum() and c[-1].isalnum() else re.escape(c)
if re.search(pattern, text, re.IGNORECASE):
return ()
# 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
for coord in {"and", "or"}:
if re.search(rf"\b{coord}\b", text, re.IGNORECASE):
return ()
if "," in text:
return ()
evidence = SourceSpan(
text[match.start() : match.end()],
match.start(),
match.end(),
)
return (propose_construction("state_change.unary_delta", (evidence,)),)