diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index 1614d855..303bd63e 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -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", diff --git a/generate/foundational_families.py b/generate/foundational_families.py index 30a445da..ca14ee8c 100644 --- a/generate/foundational_families.py +++ b/generate/foundational_families.py @@ -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.", diff --git a/generate/problem_frame.py b/generate/problem_frame.py index 82685707..b5a1e075 100644 --- a/generate/problem_frame.py +++ b/generate/problem_frame.py @@ -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, ) diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index 79c0a169..78eca474 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -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"(? 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\d+(?:\.\d+)?)\s+(?P[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} diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index af5e6863..cfd181a8 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -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" diff --git a/tests/test_construction_affordances.py b/tests/test_construction_affordances.py index 908b7f03..d16d49ec 100644 --- a/tests/test_construction_affordances.py +++ b/tests/test_construction_affordances.py @@ -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"}, ), ), diff --git a/tests/test_construction_proposal_seam.py b/tests/test_construction_proposal_seam.py index ee5dabb7..d53facc9 100644 --- a/tests/test_construction_proposal_seam.py +++ b/tests/test_construction_proposal_seam.py @@ -57,7 +57,7 @@ MIGRATED_CASES = ( ( UNARY_DELTA_CASE, "state_change.unary_delta", - "unary_delta", + "unary_delta_transition", ), ) diff --git a/tests/test_unary_delta_proposal.py b/tests/test_unary_delta_proposal.py index 726d2838..e31fbb74 100644 --- a/tests/test_unary_delta_proposal.py +++ b/tests/test_unary_delta_proposal.py @@ -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(