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
This commit is contained in:
Shay 2026-05-30 08:36:02 -07:00 committed by GitHub
parent 390b6f376d
commit a719f1b58c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 332 additions and 102 deletions

View file

@ -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

View file

@ -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",
]

View file

@ -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()

View file

@ -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)

View file

@ -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))