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:
parent
390b6f376d
commit
a719f1b58c
5 changed files with 332 additions and 102 deletions
|
|
@ -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
|
||||
|
|
|
|||
31
generate/derivation/state/__init__.py
Normal file
31
generate/derivation/state/__init__.py
Normal 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",
|
||||
]
|
||||
71
generate/derivation/state/bind.py
Normal file
71
generate/derivation/state/bind.py
Normal 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()
|
||||
110
generate/derivation/state/change.py
Normal file
110
generate/derivation/state/change.py
Normal 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)
|
||||
100
tests/test_adr_0184_s1_semantic_state_helpers.py
Normal file
100
tests/test_adr_0184_s1_semantic_state_helpers.py
Normal 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))
|
||||
Loading…
Reference in a new issue