Merge pull request #438 from AssetOverflow/feat/comparatives-pack
ADR-0176: en_core_comparatives_v1 pack + comparative-scalar extraction
This commit is contained in:
commit
0aaec09059
5 changed files with 241 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
102
generate/derivation/comparatives.py
Normal file
102
generate/derivation/comparatives.py
Normal 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)
|
||||
|
|
@ -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}
|
||||
24
language_packs/data/en_core_comparatives_v1/manifest.json
Normal file
24
language_packs/data/en_core_comparatives_v1/manifest.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
100
tests/test_adr_0176_comparatives_pack.py
Normal file
100
tests/test_adr_0176_comparatives_pack.py
Normal 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
|
||||
Loading…
Reference in a new issue