diff --git a/generate/derivation/__init__.py b/generate/derivation/__init__.py index bcd84ca5..04ed1794 100644 --- a/generate/derivation/__init__.py +++ b/generate/derivation/__init__.py @@ -7,6 +7,11 @@ guard that keeps the (Phase 3b) bounded search honest. from __future__ import annotations +from generate.derivation.clauses import ( + ClauseResult, + clause_local_results, + segment_clauses, +) from generate.derivation.comparatives import ( ComparativeScalar, comparative_step, @@ -25,6 +30,7 @@ from generate.derivation.verify import ( ) __all__ = [ + "ClauseResult", "ComparativeScalar", "GroundedDerivation", "MULTIPLICATIVE_CUES", @@ -34,12 +40,14 @@ __all__ = [ "Step", "Target", "VALID_OPS", + "clause_local_results", "comparative_step", "extract_comparative_scalars", "extract_quantities", "extract_target", "search_chain", "search_multiplicative", + "segment_clauses", "select_self_verified", "self_verifies", ] diff --git a/generate/derivation/clauses.py b/generate/derivation/clauses.py new file mode 100644 index 00000000..9055ebb2 --- /dev/null +++ b/generate/derivation/clauses.py @@ -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) diff --git a/tests/test_adr_0178_gb1_clauses.py b/tests/test_adr_0178_gb1_clauses.py new file mode 100644 index 00000000..62a1023c --- /dev/null +++ b/tests/test_adr_0178_gb1_clauses.py @@ -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)