feat(adr-0178-gb1): clause segmentation + clause-local sub-derivation
GB-1 — first slice of the comprehension-guided composer (ADR-0178). Reads the problem one clause at a time and derives each clause's LOCAL contribution; GB-2 combines them across clauses. generate/derivation/clauses.py: - segment_clauses(text): sentence-level orthographic split (ADR-0165; not grammar). - clause_local_results(text) -> tuple[ClauseResult]: per clause, 0 quantities = context (hold), 1 = leaf (its value), >=2 = bounded local search (reuses MS-3 search_chain). Refuse-preferring: ambiguous multi-quantity clause -> unresolved hold, not guessed. Locality is the guidance that bounds the search + steers grouping. 9 GB-1 tests (segmentation, leaf/context/local-product, ambiguous-holds, determinism, per-clause structure of a multi-sentence problem). Full derivation surface 86/86; ruff clean; smoke 67. Sealed; not wired into serving (ClauseResults ready for GB-2 sequential combination).
This commit is contained in:
parent
5dacc6625e
commit
c41fac2f78
3 changed files with 161 additions and 0 deletions
|
|
@ -7,6 +7,11 @@ guard that keeps the (Phase 3b) bounded search honest.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from generate.derivation.clauses import (
|
||||||
|
ClauseResult,
|
||||||
|
clause_local_results,
|
||||||
|
segment_clauses,
|
||||||
|
)
|
||||||
from generate.derivation.comparatives import (
|
from generate.derivation.comparatives import (
|
||||||
ComparativeScalar,
|
ComparativeScalar,
|
||||||
comparative_step,
|
comparative_step,
|
||||||
|
|
@ -25,6 +30,7 @@ from generate.derivation.verify import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ClauseResult",
|
||||||
"ComparativeScalar",
|
"ComparativeScalar",
|
||||||
"GroundedDerivation",
|
"GroundedDerivation",
|
||||||
"MULTIPLICATIVE_CUES",
|
"MULTIPLICATIVE_CUES",
|
||||||
|
|
@ -34,12 +40,14 @@ __all__ = [
|
||||||
"Step",
|
"Step",
|
||||||
"Target",
|
"Target",
|
||||||
"VALID_OPS",
|
"VALID_OPS",
|
||||||
|
"clause_local_results",
|
||||||
"comparative_step",
|
"comparative_step",
|
||||||
"extract_comparative_scalars",
|
"extract_comparative_scalars",
|
||||||
"extract_quantities",
|
"extract_quantities",
|
||||||
"extract_target",
|
"extract_target",
|
||||||
"search_chain",
|
"search_chain",
|
||||||
"search_multiplicative",
|
"search_multiplicative",
|
||||||
|
"segment_clauses",
|
||||||
"select_self_verified",
|
"select_self_verified",
|
||||||
"self_verifies",
|
"self_verifies",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
74
generate/derivation/clauses.py
Normal file
74
generate/derivation/clauses.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""ADR-0178 GB-1 — clause segmentation + clause-local sub-derivation.
|
||||||
|
|
||||||
|
The first slice of the comprehension-guided composer: read the problem one clause
|
||||||
|
at a time and derive each clause's *local* contribution, before GB-2 combines them
|
||||||
|
across clauses. The text's clause structure is the guidance that keeps the search
|
||||||
|
bounded and steers grouping (quantities in a clause tend to combine locally).
|
||||||
|
|
||||||
|
Segmentation is lexeme/orthographic (ADR-0165) — sentence-level via terminal
|
||||||
|
punctuation, not a grammar template. Each clause's local sub-derivation reuses the
|
||||||
|
MS-3 :func:`search_chain` (a small bounded search over that clause's few
|
||||||
|
quantities); a single-quantity clause is a leaf; a zero-quantity clause is context.
|
||||||
|
Refuse-preferring: a clause whose local op is ambiguous resolves to nothing (a
|
||||||
|
hold), it is not guessed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from generate.derivation.extract import extract_quantities
|
||||||
|
from generate.derivation.model import Quantity
|
||||||
|
from generate.derivation.multistep import search_chain
|
||||||
|
|
||||||
|
_SENTENCE_SPLIT: Final[re.Pattern[str]] = re.compile(r"(?<=[.?!])\s+")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ClauseResult:
|
||||||
|
"""A clause and its locally-derived contribution.
|
||||||
|
|
||||||
|
``value`` is the clause's local sub-result (``None`` = unresolved/hold —
|
||||||
|
context clause, or an ambiguous multi-quantity clause the local search
|
||||||
|
refused). ``resolved`` is ``True`` iff a local value was derived.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
quantities: tuple[Quantity, ...]
|
||||||
|
value: float | None
|
||||||
|
unit: str | None
|
||||||
|
resolved: bool
|
||||||
|
|
||||||
|
|
||||||
|
def segment_clauses(problem_text: str) -> tuple[str, ...]:
|
||||||
|
"""Split a problem into clauses (sentence-level, orthographic). Deterministic."""
|
||||||
|
return tuple(s.strip() for s in _SENTENCE_SPLIT.split(problem_text) if s.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def clause_local_results(problem_text: str) -> tuple[ClauseResult, ...]:
|
||||||
|
"""Derive each clause's local contribution (GB-1). Deterministic.
|
||||||
|
|
||||||
|
- 0 quantities -> context clause (unresolved).
|
||||||
|
- 1 quantity -> leaf (its value).
|
||||||
|
- >= 2 -> bounded local search (:func:`search_chain`); resolves to a
|
||||||
|
local value or holds (unresolved) on ambiguity.
|
||||||
|
"""
|
||||||
|
out: list[ClauseResult] = []
|
||||||
|
for clause in segment_clauses(problem_text):
|
||||||
|
quantities = extract_quantities(clause)
|
||||||
|
if len(quantities) == 0:
|
||||||
|
out.append(ClauseResult(clause, (), None, None, False))
|
||||||
|
elif len(quantities) == 1:
|
||||||
|
q = quantities[0]
|
||||||
|
out.append(ClauseResult(clause, quantities, q.value, q.unit, True))
|
||||||
|
else:
|
||||||
|
res = search_chain(clause)
|
||||||
|
if res is None:
|
||||||
|
out.append(ClauseResult(clause, quantities, None, None, False))
|
||||||
|
else:
|
||||||
|
out.append(
|
||||||
|
ClauseResult(clause, quantities, res.answer, res.answer_unit, True)
|
||||||
|
)
|
||||||
|
return tuple(out)
|
||||||
79
tests/test_adr_0178_gb1_clauses.py
Normal file
79
tests/test_adr_0178_gb1_clauses.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""ADR-0178 GB-1 — clause segmentation + clause-local sub-derivation.
|
||||||
|
|
||||||
|
The first slice of the comprehension-guided composer: read the problem clause by
|
||||||
|
clause and derive each clause's local contribution (GB-2 combines them). Covers
|
||||||
|
segmentation, the leaf/context/local-search cases, and the refuse-preferring hold
|
||||||
|
on ambiguous clauses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from generate.derivation import (
|
||||||
|
ClauseResult,
|
||||||
|
clause_local_results,
|
||||||
|
segment_clauses,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSegmentClauses:
|
||||||
|
def test_sentence_level_split(self) -> None:
|
||||||
|
clauses = segment_clauses(
|
||||||
|
"Sidney does 20 jacks. Brooke does three times as many. How many?"
|
||||||
|
)
|
||||||
|
assert len(clauses) == 3
|
||||||
|
assert clauses[0] == "Sidney does 20 jacks."
|
||||||
|
|
||||||
|
def test_collapses_whitespace_and_drops_empty(self) -> None:
|
||||||
|
assert segment_clauses(" A. B. ") == ("A.", "B.")
|
||||||
|
|
||||||
|
def test_deterministic(self) -> None:
|
||||||
|
t = "He has 6 boxes. Each holds 50 apples."
|
||||||
|
assert segment_clauses(t) == segment_clauses(t)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClauseLocalResults:
|
||||||
|
def test_single_clause_local_product(self) -> None:
|
||||||
|
# 0021: one clause, all quantities multiply locally -> 450
|
||||||
|
results = clause_local_results(
|
||||||
|
"He bench presses 15 pounds for 10 reps and does 3 sets."
|
||||||
|
)
|
||||||
|
assert len(results) == 1
|
||||||
|
r = results[0]
|
||||||
|
assert isinstance(r, ClauseResult)
|
||||||
|
assert r.resolved is True
|
||||||
|
assert r.value == 450.0
|
||||||
|
|
||||||
|
def test_single_quantity_clause_is_a_leaf(self) -> None:
|
||||||
|
(r,) = clause_local_results("There are 48 boxes.")
|
||||||
|
assert r.resolved is True
|
||||||
|
assert r.value == 48.0
|
||||||
|
assert r.unit == "boxes"
|
||||||
|
|
||||||
|
def test_zero_quantity_clause_is_context(self) -> None:
|
||||||
|
(r,) = clause_local_results("John is lifting weights.")
|
||||||
|
assert r.resolved is False
|
||||||
|
assert r.value is None
|
||||||
|
assert r.quantities == ()
|
||||||
|
|
||||||
|
def test_ambiguous_multi_quantity_clause_holds(self) -> None:
|
||||||
|
# two quantities, no licensed local op (no mult cue / aggregation hint)
|
||||||
|
# -> the local search refuses -> unresolved hold (not guessed)
|
||||||
|
(r,) = clause_local_results("There are 6 rows and 50 seats.")
|
||||||
|
assert r.resolved is False
|
||||||
|
assert r.value is None
|
||||||
|
assert len(r.quantities) == 2
|
||||||
|
|
||||||
|
def test_per_clause_structure_of_a_multi_sentence_problem(self) -> None:
|
||||||
|
# 0003-shape: each sentence contributes a local piece; GB-1 reports them
|
||||||
|
# per clause (GB-2 will chain them: 48 -> x24 -> x2). Each sentence here
|
||||||
|
# has a single quantity -> three leaves (48, 24, 2).
|
||||||
|
results = clause_local_results(
|
||||||
|
"There are 48 boxes. There are 24 erasers in each box. "
|
||||||
|
"They sell for 2 dollars each."
|
||||||
|
)
|
||||||
|
assert [r.resolved for r in results] == [True, True, True]
|
||||||
|
assert [r.value for r in results] == [48.0, 24.0, 2.0]
|
||||||
|
|
||||||
|
def test_deterministic(self) -> None:
|
||||||
|
t = "He bench presses 15 pounds for 10 reps and does 3 sets."
|
||||||
|
assert clause_local_results(t) == clause_local_results(t)
|
||||||
Loading…
Reference in a new issue