core/generate/derivation/clauses.py
Shay c41fac2f78 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).
2026-05-28 17:19:50 -07:00

74 lines
2.9 KiB
Python

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