280 lines
8.4 KiB
Python
280 lines
8.4 KiB
Python
"""ME-3 — additive composition matcher tests.
|
|
|
|
Covers the ``additive_quantity_composition`` extension to
|
|
``_match_multiplicative_aggregation``: extracts two same-unit
|
|
quantities connected by ``and`` and emits a pre-composed
|
|
``CandidateInitial`` whose value is the sum.
|
|
|
|
Subject binding: same-sentence Option A (refuse on missing /
|
|
pronoun / determiner). Cross-sentence subject for additive composition
|
|
is deferred (would mirror ME-2 but not needed for the v1 ME-3
|
|
canary).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
from evals.refusal_taxonomy.shape_categories import ShapeCategory
|
|
from generate.comprehension.composition_registry import (
|
|
clear_cache as clear_composition_cache,
|
|
)
|
|
from generate.math_candidate_parser import CandidateInitial
|
|
from generate.recognizer_anchor_inject import inject_from_match
|
|
from generate.recognizer_match import (
|
|
RecognizerMatch,
|
|
_match_multiplicative_aggregation,
|
|
)
|
|
from generate.recognizer_registry import RatifiedRecognizer
|
|
from packs.compile_compositions import compile_compositions
|
|
|
|
|
|
_SPEC: Mapping[str, Any] = {
|
|
"anchor_kind": "additive_quantity_composition",
|
|
"observed_units": ["pounds", "pound", "dollars", "apples", "books"],
|
|
}
|
|
|
|
_SHAPE = "bound(qty_a) + bound(qty_b)"
|
|
|
|
|
|
def setup_function(_):
|
|
clear_composition_cache()
|
|
|
|
|
|
def test_same_unit_admits_with_sum():
|
|
result = _match_multiplicative_aggregation(
|
|
"Maria saved 30 dollars in May and 20 dollars in June.", _SPEC
|
|
)
|
|
assert result is not None
|
|
a = result[0][0]
|
|
assert a["composition_shape"] == _SHAPE
|
|
assert a["subject"] == "Maria"
|
|
composed = a["composed_initial"]
|
|
assert isinstance(composed, CandidateInitial)
|
|
assert composed.initial.entity == "Maria"
|
|
assert composed.initial.quantity.value == 50
|
|
assert composed.initial.quantity.unit == "dollars"
|
|
|
|
|
|
def test_pronoun_subject_refuses():
|
|
"""Pronoun head → refuse (Option A); cross-sentence is a future brief."""
|
|
result = _match_multiplicative_aggregation(
|
|
"He lost 3 pounds in March and 4 pounds in April.", _SPEC
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_determiner_subject_refuses():
|
|
result = _match_multiplicative_aggregation(
|
|
"The dog ate 3 pounds in March and 4 pounds in April.", _SPEC
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_cross_unit_refuses():
|
|
"""Cross-unit composition has no canonical conversion in v1."""
|
|
result = _match_multiplicative_aggregation(
|
|
"Maria earned 30 dollars and 20 books.", _SPEC
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_unobserved_unit_refuses():
|
|
spec = dict(_SPEC)
|
|
spec["observed_units"] = ["dollars"] # 'pounds' missing
|
|
result = _match_multiplicative_aggregation(
|
|
"Tom gained 5 pounds and 3 pounds.", spec
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_zero_count_refuses():
|
|
result = _match_multiplicative_aggregation(
|
|
"Maria earned 0 dollars and 50 dollars.", _SPEC
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_plural_normalization():
|
|
"""pound/pounds normalize to canonical singular for matching."""
|
|
spec = dict(_SPEC)
|
|
spec["observed_units"] = ["pound"]
|
|
result = _match_multiplicative_aggregation(
|
|
"Tom gained 5 pounds and 3 pounds.", spec
|
|
)
|
|
# observed_units has 'pound' singular; the matcher should still
|
|
# accept (rstrip normalization).
|
|
assert result is not None
|
|
|
|
|
|
def test_unknown_verb_refuses():
|
|
result = _match_multiplicative_aggregation(
|
|
"Maria adopted 3 pounds and 4 pounds.", _SPEC
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_multiplicative_aggregate_path_unaffected():
|
|
"""The original detection-only aggregate path still works."""
|
|
spec = {"anchor_kind": "multiplicative_aggregate"}
|
|
result = _match_multiplicative_aggregation(
|
|
"There are 3 bags with 5 items each.", spec
|
|
)
|
|
# Detection-only — empty parsed_anchors.
|
|
assert result is not None
|
|
anchors, intent = result
|
|
assert intent == "aggregate"
|
|
assert anchors == ()
|
|
|
|
|
|
def test_wrong_anchor_kind_refuses():
|
|
spec = {"anchor_kind": "currency_per_unit_rate"}
|
|
result = _match_multiplicative_aggregation(
|
|
"Maria earned 30 dollars and 20 dollars.", spec
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_anchor_audit_fields():
|
|
result = _match_multiplicative_aggregation(
|
|
"Tom gained 5 pounds and 3 pounds.", _SPEC
|
|
)
|
|
assert result is not None
|
|
a = result[0][0]
|
|
assert {
|
|
"composition_shape",
|
|
"composed_initial",
|
|
"count_a",
|
|
"count_b",
|
|
"unit",
|
|
"subject",
|
|
"verb",
|
|
"kind",
|
|
}.issubset(a.keys())
|
|
|
|
|
|
def test_source_span_substring():
|
|
statement = "Sam earned 100 dollars and 50 dollars."
|
|
result = _match_multiplicative_aggregation(statement, _SPEC)
|
|
assert result is not None
|
|
span = result[0][0]["composed_initial"].source_span
|
|
assert span in statement
|
|
|
|
|
|
def test_no_match_returns_none():
|
|
result = _match_multiplicative_aggregation(
|
|
"There is nothing here.", _SPEC
|
|
)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: ratified composition entry + matcher + inject_from_match
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stage_pack(tmp_path: Path) -> Path:
|
|
pack = tmp_path / "en_core_math_v1"
|
|
comp_dir = pack / "compositions"
|
|
comp_dir.mkdir(parents=True)
|
|
(comp_dir / "additive_composition.jsonl").write_text(
|
|
json.dumps(
|
|
{
|
|
"surface_pattern": _SHAPE,
|
|
"composition_category": "additive_composition",
|
|
"polarity": "affirms",
|
|
"provenance": "test_me3",
|
|
"evidence_hashes": [],
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
_, sha = compile_compositions(pack)
|
|
(pack / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"pack_id": "en_core_math_v1",
|
|
"checksum": "x",
|
|
"composition_checksum": sha,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return pack
|
|
|
|
|
|
def _patch_pack_root(monkeypatch, pack_path: Path) -> None:
|
|
from generate.comprehension import composition_registry as cr
|
|
|
|
monkeypatch.setattr(cr, "_DEFAULT_PACK_RELPATH", pack_path)
|
|
monkeypatch.setattr(cr, "_repo_root", lambda: Path("/"))
|
|
|
|
|
|
def _make_match(anchors: tuple[Mapping[str, Any], ...]) -> RecognizerMatch:
|
|
class _FakeRec:
|
|
spec_id = "test_me3"
|
|
|
|
return RecognizerMatch(
|
|
recognizer=_FakeRec(), # type: ignore[arg-type]
|
|
category=ShapeCategory.MULTIPLICATIVE_AGGREGATION,
|
|
outcome="admissible",
|
|
graph_intent="aggregate",
|
|
parsed_anchors=anchors,
|
|
)
|
|
|
|
|
|
def test_end_to_end_additive_admits(monkeypatch, tmp_path):
|
|
pack = _stage_pack(tmp_path)
|
|
_patch_pack_root(monkeypatch, pack)
|
|
|
|
statement = "Maria saved 30 dollars in May and 20 dollars in June."
|
|
result = _match_multiplicative_aggregation(statement, _SPEC)
|
|
assert result is not None
|
|
match = _make_match(result[0])
|
|
emissions = inject_from_match(match, statement)
|
|
assert len(emissions) == 1
|
|
composed = emissions[0]
|
|
assert composed.initial.entity == "Maria"
|
|
assert composed.initial.quantity.value == 50
|
|
assert composed.initial.quantity.unit == "dollars"
|
|
|
|
|
|
def test_end_to_end_falsifies_suppresses(monkeypatch, tmp_path):
|
|
pack = tmp_path / "en_core_math_v1"
|
|
comp_dir = pack / "compositions"
|
|
comp_dir.mkdir(parents=True)
|
|
(comp_dir / "additive_composition.jsonl").write_text(
|
|
json.dumps(
|
|
{
|
|
"surface_pattern": _SHAPE,
|
|
"composition_category": "additive_composition",
|
|
"polarity": "falsifies",
|
|
"provenance": "test_falsifies",
|
|
"evidence_hashes": [],
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
_, sha = compile_compositions(pack)
|
|
(pack / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"pack_id": "en_core_math_v1",
|
|
"checksum": "x",
|
|
"composition_checksum": sha,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
_patch_pack_root(monkeypatch, pack)
|
|
|
|
statement = "Maria saved 30 dollars in May and 20 dollars in June."
|
|
result = _match_multiplicative_aggregation(statement, _SPEC)
|
|
assert result is not None
|
|
match = _make_match(result[0])
|
|
emissions = inject_from_match(match, statement)
|
|
assert emissions == ()
|