From a719f1b58c91b41293e1230f740c7a4fd8b20ab4 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 30 May 2026 08:36:02 -0700 Subject: [PATCH] feat(adr-0184): extract semantic-state helper seam S1 (#490) * feat(adr-0184): add semantic-state helper package * feat(adr-0184): add referent binding helpers * feat(adr-0184): add change cue helpers * refactor(adr-0184): use semantic-state helpers in accumulation * test(adr-0184): cover semantic-state helper guards --- generate/derivation/accumulate.py | 122 +++--------------- generate/derivation/state/__init__.py | 31 +++++ generate/derivation/state/bind.py | 71 ++++++++++ generate/derivation/state/change.py | 110 ++++++++++++++++ ...test_adr_0184_s1_semantic_state_helpers.py | 100 ++++++++++++++ 5 files changed, 332 insertions(+), 102 deletions(-) create mode 100644 generate/derivation/state/__init__.py create mode 100644 generate/derivation/state/bind.py create mode 100644 generate/derivation/state/change.py create mode 100644 tests/test_adr_0184_s1_semantic_state_helpers.py diff --git a/generate/derivation/accumulate.py b/generate/derivation/accumulate.py index 9e7fb592..25facb7f 100644 --- a/generate/derivation/accumulate.py +++ b/generate/derivation/accumulate.py @@ -16,25 +16,12 @@ Reading: quantity, taken **in the anchor's unit** (``9 more`` = 9 more *apples*; the unit is inherited from the running total, which is what accumulation means). 3. **Gate** — the constructed chain runs through the unchanged self-verification - gate (grounding ∧ cue ∧ unit ∧ completeness ∧ uniqueness). The gate keeps + gate (grounding ∧ unit ∧ completeness ∧ uniqueness). The gate keeps wrong=0; this only proposes a structurally-licensed candidate. -Polarity (ordered, so the ambiguous ``gives`` is resolved, never guessed): - -* ``more`` present -> **gain** (covers ``buys/gets/… - N more`` and ``gives her N more`` — the subject is the recipient); -* else an unambiguous **loss** verb -> **loss**; -* else ``gives``/``gave`` with ``to``/``away`` -> **loss** (gives N *to* someone); -* else an unambiguous **gain** verb -> **gain**; -* else -> **refuse** (no guessing). - -Referent guard (wrong=0-critical; the ADR-0174 multi-actor hazard's defensive fix, -built minimally in the clean lane rather than resurrecting the retired resolver): -a later clause stays on the anchor's referent iff its **subject token** is a -pronoun (``He/She/They/…``) or the same name as the anchor's subject. A **new named -subject** (a different capitalised non-pronoun first token, e.g. ``Tom``) -> refuse. -Pronoun gender/number is **not** matched (that was the old resolver's trap); a new -*name* is the only signal, and it triggers refusal, not resolution. +ADR-0184 S1 extracts the reusable referent and change-cue helpers into +``generate.derivation.state``. This module remains the public accumulation +composer surface; behavior is intentionally unchanged. Sealed (no ``chat/`` import); deterministic; refuse-preferring. """ @@ -47,84 +34,15 @@ from typing import Final from generate.derivation.clauses import segment_clauses from generate.derivation.extract import extract_quantities from generate.derivation.model import GroundedDerivation, Quantity, Step +from generate.derivation.state.bind import ( + continues_anchor_referent, + leading_subject_token, +) +from generate.derivation.state.change import ( + classify_change_polarity, + select_change_cue, +) from generate.derivation.verify import Resolution, select_self_verified -from generate.math_roundtrip import _tokens - -# Closed change-cue lexeme sets (ADR-0165: lexemes, not grammar templates; refined -# by the CP ledger, not asserted complete). Sorted use keeps cue selection stable. -_GAIN_VERBS: Final[frozenset[str]] = frozenset( - { - "buys", "bought", "gets", "got", "finds", "found", "picks", "picked", - "earns", "earned", "receives", "received", "collects", "collected", - "wins", "won", "makes", "made", "gains", "gained", "adds", "added", - } -) -_LOSS_VERBS: Final[frozenset[str]] = frozenset( - { - "loses", "lost", "spends", "spent", "uses", "used", "eats", "ate", - "sells", "sold", "donates", "donated", "drops", "dropped", "removes", - "removed", "breaks", "broke", - } -) -_PRONOUNS: Final[frozenset[str]] = frozenset( - {"he", "she", "they", "it", "him", "her", "them", "his", "hers", "its", "their", "we", "i", "you"} -) - -_WORD_RE: Final[re.Pattern[str]] = re.compile(r"[A-Za-z]+") - - -def _subject_token(clause: str) -> str | None: - """The clause's leading word token (its surface subject), or None if wordless.""" - match = _WORD_RE.search(clause) - return match.group(0) if match is not None else None - - -def _same_referent(clause: str, anchor_subject: str | None) -> bool: - """True iff ``clause`` does not introduce a new *named* subject. - - Conservative: a leading pronoun continues the referent; a leading token equal - to the anchor's subject continues it; any other capitalised (named) leading - token is a *new actor* and breaks the referent (-> caller refuses). - """ - subject = _subject_token(clause) - if subject is None: - return True # wordless fragment carries no new actor - if subject.lower() in _PRONOUNS: - return True - if anchor_subject is not None and subject == anchor_subject: - return True - # A new capitalised, non-pronoun leading token is a new named actor. - return not subject[:1].isupper() - - -def _polarity(clause: str) -> int | None: - """+1 (gain), -1 (loss), or None (ambiguous / no licensed change cue -> refuse).""" - tokens = set(_tokens(clause)) - if "more" in tokens: - return +1 - loss = bool(_LOSS_VERBS & tokens) - gain = bool(_GAIN_VERBS & tokens) - gives = "gives" in tokens or "gave" in tokens - directional = "to" in tokens or "away" in tokens - if loss and not gain: - return -1 - if gives and directional and not gain and not loss: - return -1 - if gain and not loss: - return +1 - return None - - -def _cue(clause: str, polarity: int) -> str: - """A grounded cue lexeme present in the clause (for the gate's cue check).""" - tokens = set(_tokens(clause)) - if "more" in tokens and polarity > 0: - return "more" - verbs = _GAIN_VERBS if polarity > 0 else _LOSS_VERBS - present = sorted(verbs & tokens) - if present: - return present[0] - return "gives" # the only remaining licensed loss path (gives … to/away) def _build_accumulation( @@ -151,11 +69,11 @@ def _build_accumulation( if len(anchor_quantities) != 1: return None # the anchor must establish exactly one quantity (GB-3b.1 scope) start = anchor_quantities[0] - anchor_subject = _subject_token(anchor_clause) + anchor_subject = leading_subject_token(anchor_clause) steps: list[Step] = [] for clause in change_clauses: - if not _same_referent(clause, anchor_subject): + if not continues_anchor_referent(clause, anchor_subject): return None # new named actor -> referent hazard -> refuse change_quantities = list(extract_quantities(clause)) if drop_isolated_foreign and len(change_quantities) > 1: @@ -164,14 +82,14 @@ def _build_accumulation( ] if len(change_quantities) != 1: return None # one change per clause (multi-change is GB-3b.2) - polarity = _polarity(clause) + polarity = classify_change_polarity(clause) if polarity is None: return None # no unambiguous licensed change cue -> refuse change = change_quantities[0] # The change is in the running total's dimension ("9 more" = 9 more apples). operand = Quantity(value=change.value, unit=start.unit, source_token=change.source_token) op = "add" if polarity > 0 else "subtract" - steps.append(Step(op=op, operand=operand, cue=_cue(clause, polarity))) + steps.append(Step(op=op, operand=operand, cue=select_change_cue(clause, polarity))) if not steps: return None @@ -218,21 +136,21 @@ def _build_accumulation_anchor_skip(problem_text: str) -> GroundedDerivation | N return None anchor_sub, anchor_qs = quantity_subs[anchor_idx] start = anchor_qs[0] - anchor_subject = _subject_token(anchor_sub) + anchor_subject = leading_subject_token(anchor_sub) steps: list[Step] = [] for sub, qs in quantity_subs[anchor_idx + 1:]: - if not _same_referent(sub, anchor_subject): + if not continues_anchor_referent(sub, anchor_subject): return None # new named actor -> referent hazard -> refuse if len(qs) != 1: return None # one change per sub-clause (multi-change is GB-3b.2) - polarity = _polarity(sub) + polarity = classify_change_polarity(sub) if polarity is None: return None # no unambiguous licensed change cue -> refuse change = qs[0] operand = Quantity(value=change.value, unit=start.unit, source_token=change.source_token) op = "add" if polarity > 0 else "subtract" - steps.append(Step(op=op, operand=operand, cue=_cue(sub, polarity))) + steps.append(Step(op=op, operand=operand, cue=select_change_cue(sub, polarity))) if not steps: return None diff --git a/generate/derivation/state/__init__.py b/generate/derivation/state/__init__.py new file mode 100644 index 00000000..392bbeba --- /dev/null +++ b/generate/derivation/state/__init__.py @@ -0,0 +1,31 @@ +"""ADR-0184 — scoped semantic-state helper substrate. + +This package is the sealed derivation-lane home for reusable semantic reading +helpers. S1 intentionally exposes only behavior-equivalent helpers extracted +from :mod:`generate.derivation.accumulate`; no serving path imports this package, +and no new candidate behavior is introduced here. +""" + +from __future__ import annotations + +from generate.derivation.state.bind import ( + PRONOUNS, + continues_anchor_referent, + leading_subject_token, +) +from generate.derivation.state.change import ( + GAIN_VERBS, + LOSS_VERBS, + classify_change_polarity, + select_change_cue, +) + +__all__ = [ + "GAIN_VERBS", + "LOSS_VERBS", + "PRONOUNS", + "classify_change_polarity", + "continues_anchor_referent", + "leading_subject_token", + "select_change_cue", +] diff --git a/generate/derivation/state/bind.py b/generate/derivation/state/bind.py new file mode 100644 index 00000000..3bd11a38 --- /dev/null +++ b/generate/derivation/state/bind.py @@ -0,0 +1,71 @@ +"""ADR-0184 S1 — conservative referent-binding helpers. + +These helpers are behavior-equivalent extractions from +:mod:`generate.derivation.accumulate`. They are deliberately small: loose +surface subject collection plus a refusal-first same-referent guard. They do +not resolve ambiguous pronouns, do not gender-match, and do not choose the most +recent actor. A new named subject is treated as a referent hazard by callers. +""" + +from __future__ import annotations + +import re +from typing import Final + +PRONOUNS: Final[frozenset[str]] = frozenset( + { + "he", + "she", + "they", + "it", + "him", + "her", + "them", + "his", + "hers", + "its", + "their", + "we", + "i", + "you", + } +) + +_WORD_RE: Final[re.Pattern[str]] = re.compile(r"[A-Za-z]+") + + +def leading_subject_token(clause: str) -> str | None: + """Return the clause's leading word token, or ``None`` if wordless. + + This is a loose signal collector, not a grammar parser. It mirrors the + prior accumulation helper so S1 is behavior-equivalent. + """ + + match = _WORD_RE.search(clause) + return match.group(0) if match is not None else None + + +def continues_anchor_referent(clause: str, anchor_subject: str | None) -> bool: + """Whether ``clause`` can safely continue ``anchor_subject``. + + Conservative ADR-0184 rule, extracted from accumulation: + + * no leading token: no new actor signal, so allow; + * leading pronoun: allow as a continuation candidate; + * same leading subject as the anchor: allow; + * any other capitalized leading non-pronoun: new named actor, so disallow; + * lowercase leading token: no named-actor signal, so allow. + + This does **not** prove pronoun resolution. Callers still gate the resulting + candidate through grounding/completeness/pooling. Multi-actor ambiguity must + be handled by future semantic-world logic, not by choosing a most-recent actor. + """ + + subject = leading_subject_token(clause) + if subject is None: + return True + if subject.lower() in PRONOUNS: + return True + if anchor_subject is not None and subject == anchor_subject: + return True + return not subject[:1].isupper() diff --git a/generate/derivation/state/change.py b/generate/derivation/state/change.py new file mode 100644 index 00000000..c89365e6 --- /dev/null +++ b/generate/derivation/state/change.py @@ -0,0 +1,110 @@ +"""ADR-0184 S1 — conservative change-cue helpers. + +These helpers are behavior-equivalent extractions from +:mod:`generate.derivation.accumulate`. They classify only the closed gain/loss +cue set already used by GB-3b.1 and return ``None`` when polarity is absent or +ambiguous. Cue hits propose semantic change frames; they never commit answers. +""" + +from __future__ import annotations + +from typing import Final + +from generate.math_roundtrip import _tokens + +# Closed change-cue lexeme sets (ADR-0165: lexemes, not grammar templates; refined +# by the CP ledger, not asserted complete). Sorted use keeps cue selection stable. +GAIN_VERBS: Final[frozenset[str]] = frozenset( + { + "buys", + "bought", + "gets", + "got", + "finds", + "found", + "picks", + "picked", + "earns", + "earned", + "receives", + "received", + "collects", + "collected", + "wins", + "won", + "makes", + "made", + "gains", + "gained", + "adds", + "added", + } +) +LOSS_VERBS: Final[frozenset[str]] = frozenset( + { + "loses", + "lost", + "spends", + "spent", + "uses", + "used", + "eats", + "ate", + "sells", + "sold", + "donates", + "donated", + "drops", + "dropped", + "removes", + "removed", + "breaks", + "broke", + } +) + + +def classify_change_polarity(clause: str) -> int | None: + """Return ``+1`` for gain, ``-1`` for loss, or ``None`` to refuse. + + Ordering is behavior-equivalent with the prior accumulation helper: + + * ``more`` present -> gain; + * else an unambiguous loss verb -> loss; + * else ``gives``/``gave`` with ``to``/``away`` -> loss; + * else an unambiguous gain verb -> gain; + * else refuse by returning ``None``. + """ + + tokens = set(_tokens(clause)) + if "more" in tokens: + return +1 + loss = bool(LOSS_VERBS & tokens) + gain = bool(GAIN_VERBS & tokens) + gives = "gives" in tokens or "gave" in tokens + directional = "to" in tokens or "away" in tokens + if loss and not gain: + return -1 + if gives and directional and not gain and not loss: + return -1 + if gain and not loss: + return +1 + return None + + +def select_change_cue(clause: str, polarity: int) -> str: + """Return a grounded cue lexeme present in ``clause`` for ``polarity``. + + The returned cue is consumed by the existing derivation verifier's cue-grounding + clause. This function assumes ``polarity`` was produced by + :func:`classify_change_polarity` for the same clause. + """ + + tokens = set(_tokens(clause)) + if "more" in tokens and polarity > 0: + return "more" + verbs = GAIN_VERBS if polarity > 0 else LOSS_VERBS + present = sorted(verbs & tokens) + if present: + return present[0] + return "gives" # the only remaining licensed loss path (gives … to/away) diff --git a/tests/test_adr_0184_s1_semantic_state_helpers.py b/tests/test_adr_0184_s1_semantic_state_helpers.py new file mode 100644 index 00000000..c3fdb0a6 --- /dev/null +++ b/tests/test_adr_0184_s1_semantic_state_helpers.py @@ -0,0 +1,100 @@ +"""ADR-0184 S1 — semantic-state helper extraction tests. + +S1 is intentionally behavior-equivalent: the helpers extracted from +``generate.derivation.accumulate`` must stay conservative and non-vacuous. These +tests pin the referent and polarity guard surfaces directly so future semantic +state work composes on the same wrong=0-first floor. +""" + +from __future__ import annotations + +from generate.derivation.accumulate import accumulation_candidates, compose_accumulation +from generate.derivation.state.bind import ( + continues_anchor_referent, + leading_subject_token, +) +from generate.derivation.state.change import ( + classify_change_polarity, + select_change_cue, +) + + +class TestReferentBindingHelpers: + def test_leading_subject_token_is_loose_signal_only(self) -> None: + assert leading_subject_token("Sam has 14 apples.") == "Sam" + assert leading_subject_token(" He buys 9 more apples.") == "He" + assert leading_subject_token("123 + 4") is None + + def test_pronoun_continuation_allowed(self) -> None: + assert continues_anchor_referent("He buys 9 more apples.", "Sam") is True + assert continues_anchor_referent("she gets 4 more tickets", "Lisa") is True + + def test_same_named_subject_allowed(self) -> None: + assert continues_anchor_referent("Sam buys 9 more apples.", "Sam") is True + + def test_new_named_actor_refuses(self) -> None: + assert continues_anchor_referent("Tom buys 9 more apples.", "Sam") is False + + def test_lowercase_leading_token_is_not_a_new_named_actor(self) -> None: + # Behavior-equivalent with the original accumulation helper: lowercase + # leading words carry no named-actor signal, so they do not by themselves + # trip the referent guard. + assert continues_anchor_referent("then buys 9 more apples", "Sam") is True + + +class TestChangeCueHelpers: + def test_more_takes_gain_precedence(self) -> None: + clause = "Her teacher gives her 5 more pencils." + assert classify_change_polarity(clause) == +1 + assert select_change_cue(clause, +1) == "more" + + def test_gain_verb_without_more(self) -> None: + clause = "He finds 7 on the playground." + assert classify_change_polarity(clause) == +1 + assert select_change_cue(clause, +1) == "finds" + + def test_loss_verb(self) -> None: + clause = "She eats 8 apples." + assert classify_change_polarity(clause) == -1 + assert select_change_cue(clause, -1) == "eats" + + def test_directional_gives_is_loss(self) -> None: + clause = "She gives 10 to her friend." + assert classify_change_polarity(clause) == -1 + assert select_change_cue(clause, -1) == "gives" + + def test_unlicensed_change_refuses(self) -> None: + assert classify_change_polarity("She owns 4 tickets.") is None + + def test_mixed_gain_and_loss_refuses_without_more_override(self) -> None: + # Both cue sets present and no 'more' override -> ambiguous, so refuse. + assert classify_change_polarity("She buys and sells 4 apples.") is None + + +class TestAccumulationStillUsesEquivalentSemantics: + def test_clean_accumulation_still_commits(self) -> None: + result = compose_accumulation( + "Sam has 14 apples. He buys 9 more. How many apples does Sam have now?" + ) + assert result is not None + assert result.answer == 23.0 + + def test_new_actor_still_refuses(self) -> None: + assert ( + compose_accumulation( + "Sam has 14 apples. Tom buys 9 more. How many apples does Sam have?" + ) + is None + ) + + def test_anchor_skip_referent_guard_still_blocks_new_actor(self) -> None: + same_referent = ( + "A train travels at 60 miles per hour for 2 hours. Tom has 8 tickets and " + "he buys 4 more tickets. How many tickets does Tom have?" + ) + new_actor = ( + "A train travels at 60 miles per hour for 2 hours. Tom has 8 tickets and " + "Sara buys 4 more tickets. How many tickets does Tom have?" + ) + assert any(d.answer == 12.0 for d in accumulation_candidates(same_referent)) + assert all(d.answer != 12.0 for d in accumulation_candidates(new_actor))