core/tests/test_adr_0179_extract.py
Shay 65d857e72a
feat(adr-0179): integrate EX-1/EX-4/EX-5 extraction richness (sealed lane) (#455)
Reconciles ChatGPT's four independently-branched extraction PRs (#451/#452/
#453/#454) into one coherent generate/derivation/extract.py. They each rewrote
the same file + same new test off main, so they conflicted pairwise and needed
integration, not a merge.

Integrated (span-tracked, most-specific-pass-first so numbers are never double
counted):
- EX-1 word-numbers (#452): reuses WORD_NUMBERS; tens-one hyphen compounds;
  factor-bearing half/third/quarter excluded.
- EX-4 list-unit inheritance (#451): bare numeric list with one trailing unit.
- EX-5 sentence-final numbers (#454): bare final number with empty unit.

Deferred: EX-3 multi-word units (#453). Its greedy lowercase span reads
"6 apples and 4 apples" as unit "apples and", regressing GB-2's
test_same_unit_list_sums, and still can't recover real multi-word units from
0024-class text ("jumping jacks on"). Needs a tighter rule; see
docs/handoff/AUDIT-ADR-0179-EX-RECONCILE.md.

Verification (sealed lane only; chat/ does not import this module):
- Serving frozen: lane-SHA 8/8 match, generate_claims --check OK -> 3/47/0
  byte-identical, wrong=0 held.
- Sealed practice improved 4/2/44 -> 4/1/45: case 0025 flips wrong->refused.
  EX-1 reads "three", so completeness sees a quantity the 6x50 chain omits and
  refuses the spurious 300 (gold 1200) instead of committing it.
- No new test failures (3 pre-existing on main).

Also fixes stale test drift from EX-2 (#447): TestDecimalGroundingGapIsDeferred
asserted decimals still refuse, but #447 made $0.75-class resolve to 864.
Renamed to TestDecimalGroundingResolves and updated to assert the flip.

Honest scope note: EX-4 does NOT unblock real case 0024 (its PR test used a
fabricated bare-list paraphrase). TestRealCase0024StillBlocked pins the true
boundary.
2026-05-29 08:43:03 -07:00

118 lines
4.9 KiB
Python

"""ADR-0179 — extraction richness tests (sealed lane: generate/derivation/extract).
Integration of the EX-1 (word-numbers), EX-4 (list-unit inheritance), and EX-5
(sentence-final numbers) sub-phases. EX-3 (multi-word units) is deferred — see
the module docstring and docs/handoff/AUDIT-ADR-0179-EX-RECONCILE.md — so there
is no multi-word-unit test class here by design.
Every assertion is a plain input-string → extracted-quantity check. Over-extraction
in this lane costs refusals (the gate is refuse-preferring), never wrong answers.
"""
from __future__ import annotations
from generate.derivation import extract_quantities
def _triples(text: str) -> list[tuple[float, str, str]]:
return [(q.value, q.unit, q.source_token) for q in extract_quantities(text)]
class TestEX1WordNumbers:
def test_word_number_extracts_as_quantity(self) -> None:
assert _triples("She picked three apples.") == [(3.0, "apples", "three")]
def test_digit_and_word_numbers_extract_left_to_right(self) -> None:
assert _triples("She picked 2 apples and three oranges.") == [
(2.0, "apples", "2"),
(3.0, "oranges", "three"),
]
def test_hyphenated_tens_one_compound(self) -> None:
assert _triples("The team scored twenty-four points.") == [
(24.0, "points", "twenty-four"),
]
def test_non_tens_one_compound_is_not_guessed(self) -> None:
# "one-third" is not a tens-one compound; the resolver declines the whole
# hyphen match rather than invent a composition rule (no fraction support,
# no fall-back to "one" alone). Conservative: nothing extracted.
assert _triples("He ate one-third pizza.") == []
def test_factor_words_are_not_extracted_as_counts(self) -> None:
# half/third/quarter read as divisors, not counts — excluded from EX-1.
assert _triples("She drank half cup.") == []
class TestEX4ListUnitInheritance:
def test_trailing_unit_attaches_to_every_number_in_list(self) -> None:
qs = extract_quantities("20, 36, 40 and 50 push-ups")
assert [q.value for q in qs] == [20.0, 36.0, 40.0, 50.0]
assert [q.unit for q in qs] == ["push-ups"] * 4
assert [q.source_token for q in qs] == ["20", "36", "40", "50"]
def test_left_to_right_order_with_surrounding_quantities(self) -> None:
assert _triples("She had 2 bags, then did 20, 36 and 40 reps.") == [
(2.0, "bags", "2"),
(20.0, "reps", "20"),
(36.0, "reps", "36"),
(40.0, "reps", "40"),
]
def test_list_numbers_not_double_counted(self) -> None:
# the trailing number's "50 push-ups" must not also surface via the
# single-unit pass.
qs = extract_quantities("20, 36, 40 and 50 push-ups")
assert len(qs) == 4
class TestEX5SentenceFinalNumbers:
def test_sentence_final_number_extracts_with_empty_unit(self) -> None:
assert _triples("She had 5.") == [(5.0, "", "5")]
def test_question_final_number_extracts_with_empty_unit(self) -> None:
assert _triples("The answer is 7?") == [(7.0, "", "7")]
def test_unit_quantity_and_final_number_extract_left_to_right(self) -> None:
assert _triples("She picked 6 apples and had 4.") == [
(6.0, "apples", "6"),
(4.0, "", "4"),
]
def test_number_with_unit_is_not_duplicated_as_final_number(self) -> None:
assert _triples("She picked 6 apples.") == [(6.0, "apples", "6")]
class TestNoRegression:
def test_simple_digit_units_still_work(self) -> None:
assert _triples("She picked 6 apples and 4 apples.") == [
(6.0, "apples", "6"),
(4.0, "apples", "4"),
]
def test_decimal_value_preserved(self) -> None:
assert _triples("It costs 0.75 dollars.") == [(0.75, "dollars", "0.75")]
class TestRealCase0024StillBlocked:
"""Honest pin: the EX-4 unit-list pattern does NOT recover real case 0024.
The actual gold text interleaves numbers with temporal phrases
("36 on Tuesday, 40 on Wednesday"), so the bare-list regex never fires and the
36/40/50 do not inherit "jumping jacks". This test documents the current
boundary so no future change silently claims 0024 is "unblocked" without
proving the (20+36+40+50)*3 = 438 chain end-to-end.
"""
def test_only_first_number_carries_the_unit(self) -> None:
qs = extract_quantities(
"Sidney does 20 jumping jacks on Monday, 36 on Tuesday, "
"40 on Wednesday, and 50 on Thursday."
)
values = [q.value for q in qs]
assert values == [20.0, 36.0, 40.0, 50.0]
# 20 keeps a unit word; the bare 36/40/50 do not inherit "jumping" — they
# are sentence-internal, not a bare comma-list, so EX-4 cannot reach them.
units = [q.unit for q in qs]
assert units[0] == "jumping"
assert len(set(units)) > 1 # NOT a same-unit list -> compose refuses