fix(kernel): complete unary-delta conformance alignment

This commit is contained in:
Shay 2026-06-21 22:07:12 -07:00
parent 61964c5cd4
commit 59a919e347
4 changed files with 133 additions and 55 deletions

View file

@ -516,11 +516,19 @@ _UNARY_DELTA_FAMILY = ConstructionFamily(
"unary_delta_relation_ambiguous", "unary_delta_relation_ambiguous",
"action_cue_unbound", "action_cue_unbound",
"delta_quantity_unbound", "delta_quantity_unbound",
"delta_quantity_ambiguous",
"changed_object_unbound", "changed_object_unbound",
"changed_object_ambiguous",
"local_binding_relation_unbound",
"direction_unbound", "direction_unbound",
"quantity_kind_unresolved", "quantity_kind_unresolved",
"unit_object_conflict",
"provenance_span_inexact", "provenance_span_inexact",
"quantity_entity_nonlocal", "quantity_entity_nonlocal",
"pronoun_antecedent_unresolved",
"event_assertion_unlicensed",
"passive_voice_unsupported",
"multiple_actor_surface",
), ),
diagnostic_only=True, diagnostic_only=True,
serving_allowed=False, serving_allowed=False,

View file

@ -139,10 +139,14 @@ class GroundedUnaryDeltaCue:
span: SourceSpan span: SourceSpan
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.action_kind not in {"gain", "loss"}: if self.surface not in {"gained", "lost"}:
raise ValueError(f"invalid action_kind: {self.action_kind!r}") raise ValueError(f"invalid surface: {self.surface!r}")
if self.direction not in {"increase", "decrease"}: if self.surface == "gained":
raise ValueError(f"invalid direction: {self.direction!r}") if self.action_kind != "gain" or self.direction != "increase":
raise ValueError("invalid gained triple")
elif self.surface == "lost":
if self.action_kind != "loss" or self.direction != "decrease":
raise ValueError("invalid lost triple")
if self.surface != self.span.text: if self.surface != self.span.text:
raise ValueError( raise ValueError(
f"surface {self.surface!r} must match span text {self.span.text!r}" f"surface {self.surface!r} must match span text {self.span.text!r}"
@ -276,6 +280,14 @@ class ProblemFrameBuilder:
def add_unary_delta_cue(self, cue: GroundedUnaryDeltaCue) -> None: def add_unary_delta_cue(self, cue: GroundedUnaryDeltaCue) -> None:
self._unary_delta_cues.append(cue) self._unary_delta_cues.append(cue)
@property
def unary_delta_cue_count(self) -> int:
return len(self._unary_delta_cues)
@property
def unary_delta_cues(self) -> tuple[GroundedUnaryDeltaCue, ...]:
return tuple(self._unary_delta_cues)
def build(self) -> ProblemFrame: def build(self) -> ProblemFrame:
"""Produce the immutable ProblemFrame.""" """Produce the immutable ProblemFrame."""
return ProblemFrame( return ProblemFrame(

View file

@ -363,12 +363,7 @@ _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\??", r"\bwhat\s+will\s+the\s+(?P<entity>[A-Za-z][A-Za-z'-]*)\s+decrease\s+by\??",
re.IGNORECASE, re.IGNORECASE,
) )
_UNARY_DELTA_RE = re.compile(
r"\b(?P<subject>(?:[A-Z][A-Za-z'-]*|[Tt]he\s+[A-Za-z][A-Za-z'-]*))\s+"
r"(?P<cue>gained|lost)\s+"
r"(?P<quantity>\d+(?:\.\d+)?)\s+"
r"(?P<object>[A-Za-z][A-Za-z'-]*)\b"
)
_ACTOR_VERB_RE = re.compile( _ACTOR_VERB_RE = re.compile(
r"\b(?P<actor>[A-Z][A-Za-z'-]*)\s+" r"\b(?P<actor>[A-Z][A-Za-z'-]*)\s+"
r"(?:gave|gives|give|received|receives|spent|spends|ate|eats|bought|buys|sold|sells)\b" r"(?:gave|gives|give|received|receives|spent|spends|ate|eats|bought|buys|sold|sells)\b"
@ -546,14 +541,17 @@ def _unary_delta_proposals(
"per", "per",
"each", "each",
"ratio", "ratio",
"than",
"more than", "more than",
"less than", "less than",
"fewer than", "fewer than",
"greater than", "greater than",
"times as", "times as",
} }
if any(c in text.lower() for c in confusers): for c in confusers:
return () 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 / transaction verbs
transfer_verbs = { transfer_verbs = {
@ -595,7 +593,10 @@ def _unary_delta_proposals(
return () return ()
# List coordination / enumeration # List coordination / enumeration
if any(coord in text.lower() for coord in {"and", "or", ","}): for coord in {"and", "or"}:
if re.search(rf"\b{coord}\b", text, re.IGNORECASE):
return ()
if "," in text:
return () return ()
evidence = SourceSpan( evidence = SourceSpan(
@ -809,6 +810,7 @@ def _bound_relations(
mentions: tuple[GroundedMention, ...], mentions: tuple[GroundedMention, ...],
bindings: tuple[MentionBinding, ...], bindings: tuple[MentionBinding, ...],
proposals: tuple[ConstructionProposal, ...], proposals: tuple[ConstructionProposal, ...],
unary_delta_cues: tuple[GroundedUnaryDeltaCue, ...],
) -> tuple[BoundRelation, ...]: ) -> tuple[BoundRelation, ...]:
by_id = {m.mention_id: m for m in mentions} by_id = {m.mention_id: m for m in mentions}
relations: list[BoundRelation] = [] relations: list[BoundRelation] = []
@ -945,48 +947,52 @@ def _bound_relations(
if cue_span.text == cue_surface and cue_surface in {"gained", "lost"}: if cue_span.text == cue_surface and cue_surface in {"gained", "lost"}:
direction = "increase" if cue_surface == "gained" else "decrease" direction = "increase" if cue_surface == "gained" else "decrease"
# Locate corresponding GroundedUnaryDeltaCue's cue_id # Locate corresponding GroundedUnaryDeltaCue's cue_id
cue_id = "cue-0000" cue_id = None
for cue in unary_delta_cues:
matching_bindings = [] if cue.span.start == cue_span.start and cue.span.end == cue_span.end:
for binding in quantity_entity: cue_id = cue.cue_id
qty = by_id.get(binding.source_mention_id) break
obj = by_id.get(binding.target_mention_id) if cue_id is not None:
if qty is not None and obj is not None: matching_bindings = []
if ( for binding in quantity_entity:
cue_span.end <= qty.span.start qty = by_id.get(binding.source_mention_id)
and qty.span.end <= obj.span.start obj = by_id.get(binding.target_mention_id)
): if qty is not None and obj is not None:
segment = text[cue_span.start : obj.span.end] if (
if not any(marker in segment for marker in ".!?"): cue_span.end <= qty.span.start
matching_bindings.append((binding, qty, obj)) and qty.span.end <= obj.span.start
if len(matching_bindings) == 1: ):
binding, quantity, obj = matching_bindings[0] segment = text[cue_span.start : obj.span.end]
roles = ( if not any(marker in segment for marker in ".!?"):
BoundRole( matching_bindings.append((binding, qty, obj))
"action_cue", if len(matching_bindings) == 1:
cue_id, binding, quantity, obj = matching_bindings[0]
"span", roles = (
(cue_span,), BoundRole(
), "action_cue",
BoundRole( cue_id,
"delta_quantity", "span",
quantity.mention_id, (cue_span,),
quantity.kind, ),
(quantity.span,), BoundRole(
), "delta_quantity",
BoundRole( quantity.mention_id,
"changed_object", obj.mention_id, obj.kind, (obj.span,) quantity.kind,
), (quantity.span,),
BoundRole("direction", direction, "direction", (cue_span,)), ),
) BoundRole(
relations.append( "changed_object", obj.mention_id, obj.kind, (obj.span,)
BoundRelation( ),
relation_id="", BoundRole("direction", direction, "direction", (cue_span,)),
relation_type="unary_delta", )
roles=roles, relations.append(
evidence_spans=(cue_span, quantity.span, obj.span), 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)) decrease_matches = list(_DECREASE_TO_FRACTION_RE.finditer(text))
if len(decrease_matches) == 1: if len(decrease_matches) == 1:
@ -1302,7 +1308,7 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
action_kind = "gain" if surface == "gained" else "loss" action_kind = "gain" if surface == "gained" else "loss"
direction = "increase" if surface == "gained" else "decrease" direction = "increase" if surface == "gained" else "decrease"
cue = GroundedUnaryDeltaCue( cue = GroundedUnaryDeltaCue(
cue_id=f"cue-{len(builder._unary_delta_cues):04d}", cue_id=f"cue-{builder.unary_delta_cue_count:04d}",
surface=surface, surface=surface,
action_kind=action_kind, action_kind=action_kind,
direction=direction, direction=direction,
@ -1332,6 +1338,7 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
mentions, mentions,
bindings, bindings,
(*quantity_entity_proposals, *unary_delta_proposals), (*quantity_entity_proposals, *unary_delta_proposals),
builder.unary_delta_cues,
): ):
builder.add_bound_relation(relation) builder.add_bound_relation(relation)
bound_target = _bound_question_target(problem_text, mentions) bound_target = _bound_question_target(problem_text, mentions)

View file

@ -561,3 +561,54 @@ def test_forbidden_import_coupling_checks() -> None:
assert forbid not in imported assert forbid not in imported
for imp in imported: for imp in imported:
assert not imp.startswith(forbid) assert not imp.startswith(forbid)
def test_unary_delta_cue_conformance() -> None:
from generate.problem_frame import GroundedUnaryDeltaCue
from generate.kernel_facts import SourceSpan
# 1. Invalid surface/action_kind/direction triples must raise ValueError
span = SourceSpan("gained", 0, 6)
with pytest.raises(ValueError):
GroundedUnaryDeltaCue("cue-0000", "gained", "loss", "increase", span)
with pytest.raises(ValueError):
GroundedUnaryDeltaCue("cue-0000", "gained", "gain", "decrease", span)
with pytest.raises(ValueError):
GroundedUnaryDeltaCue("cue-0000", "lost", "gain", "decrease", span)
with pytest.raises(ValueError):
GroundedUnaryDeltaCue("cue-0000", "lost", "loss", "increase", span)
with pytest.raises(ValueError):
GroundedUnaryDeltaCue("cue-0000", "gained", "gain", "increase", SourceSpan("lost", 0, 4))
# Valid triples must succeed
c1 = GroundedUnaryDeltaCue("cue-0001", "gained", "gain", "increase", span)
assert c1.cue_id == "cue-0001"
# 2. Test exact span resolution in _bound_relations
from generate.problem_frame_builder import _bound_relations, GroundedMention, MentionBinding, ConstructionProposal
mentions = (
GroundedMention("m-0000", "quantity", "3", SourceSpan("3", 7, 8)),
GroundedMention("m-0001", "object", "apples", SourceSpan("apples", 9, 15)),
)
bindings = (
MentionBinding("b-0000", "quantity_entity", "m-0000", "m-0001", (SourceSpan("3 apples", 7, 15),)),
)
proposals = (
ConstructionProposal(
family_id="state_change.unary_delta",
relation_type="unary_delta",
candidate_organ="unary_delta_transition",
evidence_spans=(span,),
status="proposed",
diagnostic_only=True,
serving_allowed=False,
),
)
# If the cue has a different ID and is passed in, _bound_relations must resolve and use it
cues = (
GroundedUnaryDeltaCue("cue-1234", "gained", "gain", "increase", span),
)
relations = _bound_relations("gained 3 apples", mentions, bindings, proposals, cues)
assert len(relations) == 1
action_cue_role = next(r for r in relations[0].roles if r.role == "action_cue")
assert action_cue_role.target_id == "cue-1234"