Merge pull request #438 from AssetOverflow/feat/comparatives-pack

ADR-0176: en_core_comparatives_v1 pack + comparative-scalar extraction
This commit is contained in:
Shay 2026-05-28 16:15:44 -07:00 committed by GitHub
commit 0aaec09059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 241 additions and 0 deletions

View file

@ -7,6 +7,10 @@ guard that keeps the (Phase 3b) bounded search honest.
from __future__ import annotations
from generate.derivation.comparatives import (
ComparativeScalar,
extract_comparative_scalars,
)
from generate.derivation.extract import extract_quantities
from generate.derivation.model import GroundedDerivation, Quantity, Step, VALID_OPS
from generate.derivation.search import MULTIPLICATIVE_CUES, search_multiplicative
@ -18,6 +22,7 @@ from generate.derivation.verify import (
)
__all__ = [
"ComparativeScalar",
"GroundedDerivation",
"MULTIPLICATIVE_CUES",
"Quantity",
@ -25,6 +30,7 @@ __all__ = [
"SelfVerification",
"Step",
"VALID_OPS",
"extract_comparative_scalars",
"extract_quantities",
"search_multiplicative",
"select_self_verified",

View file

@ -0,0 +1,102 @@
"""ADR-0176 — comparative-scalar extraction from the en_core_comparatives_v1 pack.
Turns comparative lexemes into the scalar *operation* they license the
irreducible world-facts the engine cannot derive from arithmetic (ADR-0175
section 10): ``twice`` -> x2, ``half`` -> x0.5, ``triple`` -> x3, and the
``<number> times`` pattern -> x<number>.
This supplies only the **scalar primitive**. *Which* quantity the scalar applies
to (the referent) is resolved by the multi-step search (ADR-0176), not here.
Closed-set + refusal-preferring: an uncovered comparative yields nothing, so the
search refuses rather than guesses. Lexeme-level per ADR-0165 (a comparative is an
orthographic shape; ``<number> times`` is number + lexeme).
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Final
from generate.math_roundtrip import WORD_NUMBERS
_PACK_DIR: Final[Path] = (
Path(__file__).resolve().parents[2]
/ "language_packs"
/ "data"
/ "en_core_comparatives_v1"
)
@dataclass(frozen=True, slots=True)
class ComparativeScalar:
"""A comparative's scalar operation. ``cue`` is the licensing lexeme."""
op: str # "multiply"
scalar: float
source_span: str
cue: str
@lru_cache(maxsize=1)
def _load_comparatives() -> dict[str, tuple[str, float]]:
"""Load the closed-set comparative lexeme -> (op, scalar) map from the pack."""
out: dict[str, tuple[str, float]] = {}
path = _PACK_DIR / "comparatives.jsonl"
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
entry = json.loads(line)
out[entry["lexeme"]] = (entry["op"], float(entry["scalar"]))
return out
# Word-numbers usable as the N in "<N> times" (e.g. "three times" -> x3).
_WORD_NUM_ALT: Final[str] = "|".join(
re.escape(w) for w in sorted(WORD_NUMBERS, key=len, reverse=True)
)
_N_TIMES_RE: Final[re.Pattern[str]] = re.compile(
rf"(?i)\b(\d+(?:\.\d+)?|{_WORD_NUM_ALT})\s+times\b"
)
def _resolve_number(token: str) -> float | None:
try:
return float(token)
except ValueError:
return float(WORD_NUMBERS[token.lower()]) if token.lower() in WORD_NUMBERS else None
def extract_comparative_scalars(text: str) -> tuple[ComparativeScalar, ...]:
"""Extract comparative scalars in left-to-right text order. Deterministic.
Emits a :class:`ComparativeScalar` for each present fixed comparative lexeme
(``twice``/``half``/...) and each ``<number> times`` phrase. ``<number> times``
takes precedence over a bare ``times`` so a fixed lexeme is never double-counted.
"""
pack = _load_comparatives()
found: list[tuple[int, ComparativeScalar]] = []
# "<number> times" pattern (scalar = the number).
for m in _N_TIMES_RE.finditer(text):
n = _resolve_number(m.group(1))
if n is None or n <= 0:
continue
found.append(
(m.start(), ComparativeScalar("multiply", n, m.group(0), "times"))
)
# Fixed comparative lexemes (word-boundary, case-insensitive).
for lexeme, (op, scalar) in pack.items():
for m in re.finditer(rf"(?i)\b{re.escape(lexeme)}\b", text):
found.append(
(m.start(), ComparativeScalar(op, scalar, m.group(0), lexeme))
)
found.sort(key=lambda pair: (pair[0], pair[1].cue))
return tuple(cs for _, cs in found)

View file

@ -0,0 +1,9 @@
{"lexeme":"double","op":"multiply","scalar":2.0}
{"lexeme":"doubled","op":"multiply","scalar":2.0}
{"lexeme":"half","op":"multiply","scalar":0.5}
{"lexeme":"quadruple","op":"multiply","scalar":4.0}
{"lexeme":"quadrupled","op":"multiply","scalar":4.0}
{"lexeme":"quarter","op":"multiply","scalar":0.25}
{"lexeme":"triple","op":"multiply","scalar":3.0}
{"lexeme":"tripled","op":"multiply","scalar":3.0}
{"lexeme":"twice","op":"multiply","scalar":2.0}

View file

@ -0,0 +1,24 @@
{
"pack_id": "en_core_comparatives_v1",
"schema_version": 1,
"description": "English multiplicative-comparative primitives for ADR-0176 multi-step composition. Maps comparative lexemes to a scalar operation (twice -> x2, half -> x0.5). These are irreducible world-facts the engine cannot derive from arithmetic (ADR-0175 section 10). Closed-set, refusal-preferring on uncovered comparatives. The '<number> times' pattern is handled by the extractor, not enumerated here. Expansion via the standard HITL corridor (ADR-0150/0152).",
"adr": "ADR-0176",
"files": [
{
"path": "comparatives.jsonl",
"schema": {
"lexeme": "str - comparative surface lexeme (lowercase)",
"op": "literal: multiply - the operation the comparative licenses",
"scalar": "float - the multiplier (twice=2.0, half=0.5, quarter=0.25)"
},
"entry_count": 9,
"checksum": "2cdfa48093ad81eea36a257a0092c305256344048f5ce74671c119b1e51adfb6"
}
],
"discipline": {
"closed_set": "Only unambiguous multiplicative-scalar comparatives are included. The referent (which quantity the scalar applies to) is NOT resolved here - that is the multi-step search's job (ADR-0176). This pack supplies only the scalar primitive.",
"ambiguity": "Comparatives whose scalar is non-terminating or context-dependent (e.g. 'a third' -> 1/3, 'several', 'a few', 'more') MUST NOT be added without explicit HITL review. Refusal-preferring: an uncovered comparative yields no scalar, and the search refuses rather than guesses.",
"expansion": "New entries enter via core teaching propose-from-exemplars when multi-step practice produces refusal evidence on uncovered comparatives. Each addition reviewed against the unambiguous-terminating-scalar criterion.",
"checksum_rule": "checksum is sha256 of the exact bytes of comparatives.jsonl as written to disk (CLAUDE.md pack discipline)."
}
}

View file

@ -0,0 +1,100 @@
"""ADR-0176 — en_core_comparatives_v1 pack + comparative-scalar extraction.
The curated, closed-set, refusal-preferring world-fact primitives that multi-step
composition needs (twice -> x2, half -> x0.5, '<N> times' -> xN). Tests cover the
extractor behaviour, determinism, refusal-preferring discipline, and pack
integrity (manifest checksum matches the bytes on disk CLAUDE.md pack rule).
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from generate.derivation import ComparativeScalar, extract_comparative_scalars
_PACK = Path(__file__).resolve().parents[1] / "language_packs" / "data" / "en_core_comparatives_v1"
# ---------------------------------------------------------------------------
# extraction
# ---------------------------------------------------------------------------
class TestExtractComparativeScalars:
def test_twice(self) -> None:
cs = extract_comparative_scalars("She has twice as many apples.")
assert cs == (ComparativeScalar("multiply", 2.0, "twice", "twice"),)
def test_half(self) -> None:
cs = extract_comparative_scalars("He keeps half of the cards.")
assert cs == (ComparativeScalar("multiply", 0.5, "half", "half"),)
def test_word_number_times(self) -> None:
cs = extract_comparative_scalars("Brooke does three times as many jumping jacks.")
assert cs == (ComparativeScalar("multiply", 3.0, "three times", "times"),)
def test_digit_times(self) -> None:
cs = extract_comparative_scalars("The price is 5 times the cost.")
assert cs == (ComparativeScalar("multiply", 5.0, "5 times", "times"),)
def test_triple_and_quarter(self) -> None:
assert extract_comparative_scalars("output tripled")[0].scalar == 3.0
assert extract_comparative_scalars("a quarter of the pie")[0].scalar == 0.25
def test_multiple_in_text_order(self) -> None:
cs = extract_comparative_scalars("First it doubled, then three times more.")
# "doubled" matches the exact 'doubled' lexeme (word-boundary; not 'double')
assert [(c.scalar, c.cue) for c in cs] == [(2.0, "doubled"), (3.0, "times")]
def test_deterministic(self) -> None:
t = "twice and three times and half"
assert extract_comparative_scalars(t) == extract_comparative_scalars(t)
# ---------------------------------------------------------------------------
# refusal-preferring / closed-set discipline
# ---------------------------------------------------------------------------
class TestRefusalPreferring:
def test_uncovered_comparative_yields_nothing(self) -> None:
# "a third" (1/3, non-terminating) is deliberately NOT in the pack
assert extract_comparative_scalars("he ate a third of it") == ()
assert extract_comparative_scalars("several more apples") == ()
def test_no_comparative_yields_nothing(self) -> None:
assert extract_comparative_scalars("He has 5 apples and 3 oranges.") == ()
def test_times_requires_a_positive_number(self) -> None:
# bare "times" without a number -> no scalar (don't guess)
assert extract_comparative_scalars("he goes to the gym sometimes") == ()
def test_zero_times_not_emitted(self) -> None:
assert extract_comparative_scalars("0 times the value") == ()
# ---------------------------------------------------------------------------
# pack integrity (CLAUDE.md: checksum hashes the bytes written to disk)
# ---------------------------------------------------------------------------
class TestPackIntegrity:
def test_manifest_checksum_matches_bytes(self) -> None:
manifest = json.loads((_PACK / "manifest.json").read_text())
entry = manifest["files"][0]
data_bytes = (_PACK / entry["path"]).read_bytes()
assert hashlib.sha256(data_bytes).hexdigest() == entry["checksum"]
def test_entry_count_matches(self) -> None:
manifest = json.loads((_PACK / "manifest.json").read_text())
entry = manifest["files"][0]
lines = [ln for ln in (_PACK / entry["path"]).read_text().splitlines() if ln.strip()]
assert len(lines) == entry["entry_count"]
def test_all_entries_well_formed(self) -> None:
for line in (_PACK / "comparatives.jsonl").read_text().splitlines():
if not line.strip():
continue
e = json.loads(line)
assert set(e) == {"lexeme", "op", "scalar"}
assert e["op"] == "multiply"
assert isinstance(e["scalar"], (int, float)) and e["scalar"] > 0