core/tests/test_recognition_phase2.py
Shay 23ce6f9a06
feat(recognition): Phase 2 multi-resolution — polarity, modality, tense + adversarial refusals (#226)
Extends derive_recognizer to detect VP variation and build a Phase 2
recognizer that lifts tense, polarity, modality, and intentionality
alongside the Phase 1 agent/count/unit/relation slots.

Three-layer refusal: Layer 1 (unknown VP), Layer 2 (missing count),
Layer 3 (contradictory count spans). Phase 1 path preserved when all
teaching examples share a single VP. 8/8 tests pass.
2026-05-24 12:56:00 -07:00

249 lines
12 KiB
Python

from __future__ import annotations
import json
from recognition.anti_unifier import DerivedRecognizer, derive_recognizer, recognize
from recognition.outcome import (
EVIDENCED,
UNDETERMINED,
CONTRADICTED,
EvidenceSpan,
FeatureBundle,
NegativeEvidence,
ShapeRefusal,
FeatureEvidenceRefusal,
FeatureConsistencyRefusal,
)
def _span(tokens: tuple[str, ...], start: int, end: int) -> EvidenceSpan:
return EvidenceSpan(start=start, end=end, text=" ".join(tokens[start:end]))
def _examples() -> list[tuple[tuple[str, ...], FeatureBundle]]:
# 1. "A baker has 24 loaves"
c1 = ("A", "baker", "has", "24", "loaves")
b1 = FeatureBundle.from_mapping({
"agent": ("baker", _span(c1, 1, 2)),
"count": (24, _span(c1, 3, 4)),
"unit": ("loaf", _span(c1, 4, 5)),
"relation": ("has", _span(c1, 2, 3)),
"tense": ("present", _span(c1, 2, 3)),
"polarity": ("+", NegativeEvidence(0, len(c1), "no negator present")),
"modality": ("actual", NegativeEvidence(0, len(c1), "no modal counter-marker present")),
"intentionality": ("possession", _span(c1, 1, 3)),
})
# 2. "A baker does not have 24 loaves"
c2 = ("A", "baker", "does", "not", "have", "24", "loaves")
b2 = FeatureBundle.from_mapping({
"agent": ("baker", _span(c2, 1, 2)),
"count": (24, _span(c2, 5, 6)),
"unit": ("loaf", _span(c2, 6, 7)),
"relation": ("have", _span(c2, 4, 5)),
"tense": ("present", _span(c2, 2, 3)),
"polarity": ("-", _span(c2, 3, 4)),
"modality": ("actual", NegativeEvidence(0, len(c2), "no modal counter-marker present")),
"intentionality": ("possession", _span(c2, 1, 5)),
})
# 3. "A baker may have 24 loaves"
c3 = ("A", "baker", "may", "have", "24", "loaves")
b3 = FeatureBundle.from_mapping({
"agent": ("baker", _span(c3, 1, 2)),
"count": (24, _span(c3, 4, 5)),
"unit": ("loaf", _span(c3, 5, 6)),
"relation": ("have", _span(c3, 3, 4)),
"tense": ("present", _span(c3, 2, 3)),
"polarity": ("+", NegativeEvidence(0, len(c3), "no negator present")),
"modality": ("possibility", _span(c3, 2, 3)),
"intentionality": ("possession", _span(c3, 1, 4)),
})
# 4. "A baker had 24 loaves"
c4 = ("A", "baker", "had", "24", "loaves")
b4 = FeatureBundle.from_mapping({
"agent": ("baker", _span(c4, 1, 2)),
"count": (24, _span(c4, 3, 4)),
"unit": ("loaf", _span(c4, 4, 5)),
"relation": ("had", _span(c4, 2, 3)),
"tense": ("past", _span(c4, 2, 3)),
"polarity": ("+", NegativeEvidence(0, len(c4), "no negator present")),
"modality": ("actual", NegativeEvidence(0, len(c4), "no modal counter-marker present")),
"intentionality": ("possession", _span(c4, 1, 3)),
})
# 5. "A baker will have 24 loaves"
c5 = ("A", "baker", "will", "have", "24", "loaves")
b5 = FeatureBundle.from_mapping({
"agent": ("baker", _span(c5, 1, 2)),
"count": (24, _span(c5, 4, 5)),
"unit": ("loaf", _span(c5, 5, 6)),
"relation": ("have", _span(c5, 3, 4)),
"tense": ("future", _span(c5, 2, 3)),
"polarity": ("+", NegativeEvidence(0, len(c5), "no negator present")),
"modality": ("actual", NegativeEvidence(0, len(c5), "no modal counter-marker present")),
"intentionality": ("possession", _span(c5, 1, 4)),
})
return [(c1, b1), (c2, b2), (c3, b3), (c4, b4), (c5, b5)]
def test_derive_recognizer_phase2_is_byte_identical() -> None:
first = derive_recognizer(_examples())
second = derive_recognizer(_examples())
assert first == second
assert first.to_json() == second.to_json()
assert DerivedRecognizer.from_json(first.to_json()) == first
assert json.dumps(json.loads(first.to_json()), sort_keys=True, separators=(",", ":")) == first.to_json()
def test_positive_cases_admitted() -> None:
recognizer = derive_recognizer(_examples())
# Case 1
o1 = recognize(recognizer, ("A", "baker", "has", "24", "loaves"))
assert o1.state == EVIDENCED
assert o1.refusal_reason is None
assert o1.proposition is not None
assert o1.proposition.get("agent").value == "baker"
assert o1.proposition.get("agent").evidence == EvidenceSpan(1, 2, "baker")
assert o1.proposition.get("count").value == 24
assert o1.proposition.get("count").evidence == EvidenceSpan(3, 4, "24")
assert o1.proposition.get("unit").value == "loaf"
assert o1.proposition.get("unit").evidence == EvidenceSpan(4, 5, "loaves")
assert o1.proposition.get("relation").value == "has"
assert o1.proposition.get("relation").evidence == EvidenceSpan(2, 3, "has")
assert o1.proposition.get("tense").value == "present"
assert o1.proposition.get("tense").evidence == EvidenceSpan(2, 3, "has")
assert o1.proposition.get("polarity").value == "+"
assert isinstance(o1.proposition.get("polarity").evidence, NegativeEvidence)
assert o1.proposition.get("modality").value == "actual"
assert isinstance(o1.proposition.get("modality").evidence, NegativeEvidence)
assert o1.proposition.get("intentionality").value == "possession"
assert o1.proposition.get("intentionality").evidence == EvidenceSpan(1, 3, "baker has")
# Case 2
o2 = recognize(recognizer, ("A", "baker", "does", "not", "have", "24", "loaves"))
assert o2.state == EVIDENCED
assert o2.refusal_reason is None
assert o2.proposition is not None
assert o2.proposition.get("agent").value == "baker"
assert o2.proposition.get("count").value == 24
assert o2.proposition.get("unit").value == "loaf"
assert o2.proposition.get("relation").value == "have"
assert o2.proposition.get("relation").evidence == EvidenceSpan(4, 5, "have")
assert o2.proposition.get("tense").value == "present"
assert o2.proposition.get("tense").evidence == EvidenceSpan(2, 3, "does")
assert o2.proposition.get("polarity").value == "-"
assert o2.proposition.get("polarity").evidence == EvidenceSpan(3, 4, "not")
assert o2.proposition.get("modality").value == "actual"
assert isinstance(o2.proposition.get("modality").evidence, NegativeEvidence)
assert o2.proposition.get("intentionality").value == "possession"
assert o2.proposition.get("intentionality").evidence == EvidenceSpan(1, 5, "baker does not have")
# Case 3
o3 = recognize(recognizer, ("A", "baker", "may", "have", "24", "loaves"))
assert o3.state == EVIDENCED
assert o3.refusal_reason is None
assert o3.proposition is not None
assert o3.proposition.get("agent").value == "baker"
assert o3.proposition.get("count").value == 24
assert o3.proposition.get("unit").value == "loaf"
assert o3.proposition.get("relation").value == "have"
assert o3.proposition.get("relation").evidence == EvidenceSpan(3, 4, "have")
assert o3.proposition.get("tense").value == "present"
assert o3.proposition.get("tense").evidence == EvidenceSpan(2, 3, "may")
assert o3.proposition.get("polarity").value == "+"
assert isinstance(o3.proposition.get("polarity").evidence, NegativeEvidence)
assert o3.proposition.get("modality").value == "possibility"
assert o3.proposition.get("modality").evidence == EvidenceSpan(2, 3, "may")
assert o3.proposition.get("intentionality").value == "possession"
assert o3.proposition.get("intentionality").evidence == EvidenceSpan(1, 4, "baker may have")
# Case 4
o4 = recognize(recognizer, ("A", "baker", "had", "24", "loaves"))
assert o4.state == EVIDENCED
assert o4.refusal_reason is None
assert o4.proposition is not None
assert o4.proposition.get("agent").value == "baker"
assert o4.proposition.get("count").value == 24
assert o4.proposition.get("unit").value == "loaf"
assert o4.proposition.get("relation").value == "had"
assert o4.proposition.get("relation").evidence == EvidenceSpan(2, 3, "had")
assert o4.proposition.get("tense").value == "past"
assert o4.proposition.get("tense").evidence == EvidenceSpan(2, 3, "had")
assert o4.proposition.get("polarity").value == "+"
assert isinstance(o4.proposition.get("polarity").evidence, NegativeEvidence)
assert o4.proposition.get("modality").value == "actual"
assert isinstance(o4.proposition.get("modality").evidence, NegativeEvidence)
assert o4.proposition.get("intentionality").value == "possession"
assert o4.proposition.get("intentionality").evidence == EvidenceSpan(1, 3, "baker had")
# Case 5
o5 = recognize(recognizer, ("A", "baker", "will", "have", "24", "loaves"))
assert o5.state == EVIDENCED
assert o5.refusal_reason is None
assert o5.proposition is not None
assert o5.proposition.get("agent").value == "baker"
assert o5.proposition.get("count").value == 24
assert o5.proposition.get("unit").value == "loaf"
assert o5.proposition.get("relation").value == "have"
assert o5.proposition.get("relation").evidence == EvidenceSpan(3, 4, "have")
assert o5.proposition.get("tense").value == "future"
assert o5.proposition.get("tense").evidence == EvidenceSpan(2, 3, "will")
assert o5.proposition.get("polarity").value == "+"
assert isinstance(o5.proposition.get("polarity").evidence, NegativeEvidence)
assert o5.proposition.get("modality").value == "actual"
assert isinstance(o5.proposition.get("modality").evidence, NegativeEvidence)
assert o5.proposition.get("intentionality").value == "possession"
assert o5.proposition.get("intentionality").evidence == EvidenceSpan(1, 4, "baker will have")
def test_adversarial_refusals() -> None:
recognizer = derive_recognizer(_examples())
# Case 6: "John gave 5 apples to Mary" -> Layer 1 ShapeRefusal (wrong relation)
o6 = recognize(recognizer, ("John", "gave", "5", "apples", "to", "Mary"))
assert o6.state == UNDETERMINED
assert o6.proposition is None
assert isinstance(o6.refusal_reason, ShapeRefusal)
# Case 7: "A baker has loaves" -> Layer 2 FeatureEvidenceRefusal (missing count)
o7 = recognize(recognizer, ("A", "baker", "has", "loaves"))
assert o7.state == UNDETERMINED
assert o7.proposition is None
assert isinstance(o7.refusal_reason, FeatureEvidenceRefusal)
assert o7.refusal_reason.missing_feature == "count"
# Case 8: "A baker has 24 loaves and 12 loaves" -> Layer 3 FeatureConsistencyRefusal (count contradiction)
o8 = recognize(recognizer, ("A", "baker", "has", "24", "loaves", "and", "12", "loaves"))
assert o8.state == CONTRADICTED
assert o8.proposition is None
assert isinstance(o8.refusal_reason, FeatureConsistencyRefusal)
assert o8.refusal_reason.feature == "count"
assert len(o8.refusal_reason.spans) == 2
assert o8.refusal_reason.spans[0] == EvidenceSpan(3, 4, "24")
assert o8.refusal_reason.spans[1] == EvidenceSpan(6, 7, "12")
def test_byte_identity_across_runs() -> None:
recognizer = derive_recognizer(_examples())
cases = [
("A", "baker", "has", "24", "loaves"),
("A", "baker", "does", "not", "have", "24", "loaves"),
("A", "baker", "may", "have", "24", "loaves"),
("A", "baker", "had", "24", "loaves"),
("A", "baker", "will", "have", "24", "loaves"),
("John", "gave", "5", "apples", "to", "Mary"),
("A", "baker", "has", "loaves"),
("A", "baker", "has", "24", "loaves", "and", "12", "loaves"),
]
for case in cases:
out1 = recognize(recognizer, case)
out2 = recognize(recognizer, case)
assert out1 == out2
# Serialize and deserialize to ensure exact identical JSON payload
d1 = out1.as_dict()
d2 = out2.as_dict()
assert d1 == d2
j1 = json.dumps(d1, sort_keys=True, separators=(",", ":"))
j2 = json.dumps(d2, sort_keys=True, separators=(",", ":"))
assert j1 == j2