From 59a919e347d84b38921aa3a6df42a818656f7182 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 21 Jun 2026 22:07:12 -0700 Subject: [PATCH] fix(kernel): complete unary-delta conformance alignment --- generate/construction_affordances.py | 8 ++ generate/problem_frame.py | 20 ++++- generate/problem_frame_builder.py | 109 ++++++++++++++------------- tests/test_unary_delta_proposal.py | 51 +++++++++++++ 4 files changed, 133 insertions(+), 55 deletions(-) diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index 303bd63e..9c618a7b 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -516,11 +516,19 @@ _UNARY_DELTA_FAMILY = ConstructionFamily( "unary_delta_relation_ambiguous", "action_cue_unbound", "delta_quantity_unbound", + "delta_quantity_ambiguous", "changed_object_unbound", + "changed_object_ambiguous", + "local_binding_relation_unbound", "direction_unbound", "quantity_kind_unresolved", + "unit_object_conflict", "provenance_span_inexact", "quantity_entity_nonlocal", + "pronoun_antecedent_unresolved", + "event_assertion_unlicensed", + "passive_voice_unsupported", + "multiple_actor_surface", ), diagnostic_only=True, serving_allowed=False, diff --git a/generate/problem_frame.py b/generate/problem_frame.py index b5a1e075..3d36796f 100644 --- a/generate/problem_frame.py +++ b/generate/problem_frame.py @@ -139,10 +139,14 @@ class GroundedUnaryDeltaCue: 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 not in {"gained", "lost"}: + raise ValueError(f"invalid surface: {self.surface!r}") + if self.surface == "gained": + 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: raise ValueError( 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: 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: """Produce the immutable ProblemFrame.""" return ProblemFrame( diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index 78eca474..dda08ad1 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -363,12 +363,7 @@ _DECREASE_DELTA_QUESTION_RE = re.compile( r"\bwhat\s+will\s+the\s+(?P[A-Za-z][A-Za-z'-]*)\s+decrease\s+by\??", re.IGNORECASE, ) -_UNARY_DELTA_RE = re.compile( - r"\b(?P(?:[A-Z][A-Za-z'-]*|[Tt]he\s+[A-Za-z][A-Za-z'-]*))\s+" - r"(?Pgained|lost)\s+" - r"(?P\d+(?:\.\d+)?)\s+" - r"(?P[A-Za-z][A-Za-z'-]*)\b" -) + _ACTOR_VERB_RE = re.compile( r"\b(?P[A-Z][A-Za-z'-]*)\s+" r"(?:gave|gives|give|received|receives|spent|spends|ate|eats|bought|buys|sold|sells)\b" @@ -546,14 +541,17 @@ def _unary_delta_proposals( "per", "each", "ratio", + "than", "more than", "less than", "fewer than", "greater than", "times as", } - if any(c in text.lower() for c in confusers): - return () + 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 = { @@ -595,7 +593,10 @@ def _unary_delta_proposals( return () # 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 () evidence = SourceSpan( @@ -809,6 +810,7 @@ def _bound_relations( 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] = [] @@ -945,48 +947,52 @@ def _bound_relations( 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 = "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, 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), + 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: @@ -1302,7 +1308,7 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: 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}", + cue_id=f"cue-{builder.unary_delta_cue_count:04d}", surface=surface, action_kind=action_kind, direction=direction, @@ -1332,6 +1338,7 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: mentions, bindings, (*quantity_entity_proposals, *unary_delta_proposals), + builder.unary_delta_cues, ): builder.add_bound_relation(relation) bound_target = _bound_question_target(problem_text, mentions) diff --git a/tests/test_unary_delta_proposal.py b/tests/test_unary_delta_proposal.py index e31fbb74..04784ece 100644 --- a/tests/test_unary_delta_proposal.py +++ b/tests/test_unary_delta_proposal.py @@ -561,3 +561,54 @@ def test_forbidden_import_coupling_checks() -> None: assert forbid not in imported for imp in imported: 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"