From 63f25448627b932ed7bae28978aeba291673e2c7 Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 28 May 2026 16:07:35 -0700 Subject: [PATCH] feat(adr-0176): en_core_comparatives_v1 pack + comparative-scalar extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The curated, irreducible world-fact primitives multi-step composition needs (ADR-0175 section 10: the engine can't derive 'twice = 2' from arithmetic). The microscope flagged these via the 0015/0025/0024/0033 wrongs. language_packs/data/en_core_comparatives_v1/: 9 closed-set multiplicative comparatives (twice/double/triple/quadruple/half/quarter + inflections) -> scalar ops. manifest.json with sha256 of the bytes on disk (CLAUDE.md pack rule). Refusal-preferring: non-terminating/ambiguous comparatives (a third, several) deliberately excluded; expansion via HITL corridor. generate/derivation/comparatives.py: extract_comparative_scalars() -> ComparativeScalar(op, scalar, span, cue). Fixed lexemes + the ' times' pattern (digit or word-number via WORD_NUMBERS). Lexeme-level (ADR-0165); deterministic (text-order); supplies only the SCALAR primitive — referent binding is the multi-step search's job (ADR-0176). 14 tests incl. refusal-preferring discipline + pack integrity (manifest checksum matches bytes on disk). Verified: derivation suite 45/45; ruff clean; smoke 67; packs 141. Not wired into serving (data + extractor ready for ADR-0176 MS phases). --- generate/derivation/__init__.py | 6 ++ generate/derivation/comparatives.py | 102 ++++++++++++++++++ .../comparatives.jsonl | 9 ++ .../en_core_comparatives_v1/manifest.json | 24 +++++ tests/test_adr_0176_comparatives_pack.py | 100 +++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 generate/derivation/comparatives.py create mode 100644 language_packs/data/en_core_comparatives_v1/comparatives.jsonl create mode 100644 language_packs/data/en_core_comparatives_v1/manifest.json create mode 100644 tests/test_adr_0176_comparatives_pack.py diff --git a/generate/derivation/__init__.py b/generate/derivation/__init__.py index b8cfa540..84302248 100644 --- a/generate/derivation/__init__.py +++ b/generate/derivation/__init__.py @@ -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", diff --git a/generate/derivation/comparatives.py b/generate/derivation/comparatives.py new file mode 100644 index 00000000..cb29c571 --- /dev/null +++ b/generate/derivation/comparatives.py @@ -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 +`` times`` pattern -> x. + +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; `` 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 " 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 `` times`` phrase. `` times`` + takes precedence over a bare ``times`` so a fixed lexeme is never double-counted. + """ + pack = _load_comparatives() + found: list[tuple[int, ComparativeScalar]] = [] + + # " 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) diff --git a/language_packs/data/en_core_comparatives_v1/comparatives.jsonl b/language_packs/data/en_core_comparatives_v1/comparatives.jsonl new file mode 100644 index 00000000..0e39f21d --- /dev/null +++ b/language_packs/data/en_core_comparatives_v1/comparatives.jsonl @@ -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} diff --git a/language_packs/data/en_core_comparatives_v1/manifest.json b/language_packs/data/en_core_comparatives_v1/manifest.json new file mode 100644 index 00000000..380387af --- /dev/null +++ b/language_packs/data/en_core_comparatives_v1/manifest.json @@ -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 ' 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)." + } +} diff --git a/tests/test_adr_0176_comparatives_pack.py b/tests/test_adr_0176_comparatives_pack.py new file mode 100644 index 00000000..537966e2 --- /dev/null +++ b/tests/test_adr_0176_comparatives_pack.py @@ -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, ' 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