From 23ce6f9a062edd415b69572e77517f2cb3beabe0 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 24 May 2026 12:56:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(recognition):=20Phase=202=20multi-resoluti?= =?UTF-8?q?on=20=E2=80=94=20polarity,=20modality,=20tense=20+=20adversaria?= =?UTF-8?q?l=20refusals=20(#226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- recognition/anti_unifier.py | 361 +++++++++++++++++++++++++++---- tests/test_recognition_phase2.py | 249 +++++++++++++++++++++ 2 files changed, 564 insertions(+), 46 deletions(-) create mode 100644 tests/test_recognition_phase2.py diff --git a/recognition/anti_unifier.py b/recognition/anti_unifier.py index 798953c5..b27c32b2 100644 --- a/recognition/anti_unifier.py +++ b/recognition/anti_unifier.py @@ -8,12 +8,15 @@ from dataclasses import dataclass from typing import Any, Iterable, Mapping, Sequence from recognition.outcome import ( + CONTRADICTED, EVIDENCED, UNDETERMINED, BoundFeature, EvidenceSpan, FeatureBundle, + FeatureConsistencyRefusal, FeatureEvidence, + FeatureEvidenceRefusal, NegativeEvidence, RecognitionOutcome, RecognitionProvenance, @@ -90,52 +93,183 @@ def derive_recognizer(examples: Sequence[tuple[TokenSequence, FeatureBundle]]) - normalized = tuple((tuple(tokens), bundle) for tokens, bundle in examples) teaching_set_id = _teaching_set_id(tokens for tokens, _bundle in normalized) feature_names = _feature_names(normalized) - slot_names = _slot_feature_names(normalized, feature_names) - absent_features = _absent_uniform_features(normalized, feature_names, slot_names) - relation = _uniform_feature_value(normalized, "relation") - relation_token = str(relation) - anchors = tuple(_single_token_index(tokens, relation_token) for tokens, _bundle in normalized) + # Check if we have verb/auxiliary phrase variations + unique_vps = set() + for tokens, bundle in normalized: + agent_feat = bundle.get("agent") + count_feat = bundle.get("count") + if agent_feat is not None and count_feat is not None: + agent_ev = agent_feat.evidence + count_ev = count_feat.evidence + if isinstance(agent_ev, EvidenceSpan) and isinstance(count_ev, EvidenceSpan): + vp_tokens = tokens[agent_ev.end : count_ev.start] + unique_vps.add(" ".join(vp_tokens)) - prefix_widths = tuple(anchor for anchor in anchors) - suffix_widths = tuple(len(tokens) - anchor - 1 for (tokens, _bundle), anchor in zip(normalized, anchors)) - if min(prefix_widths) < 1: - raise ValueError("agent slot must contain at least one token") - if set(suffix_widths) != {2}: - raise ValueError("Phase 1 expects count and unit slots after the relation anchor") + if len(unique_vps) <= 1: + # Phase 1 logic + slot_names = _slot_feature_names(normalized, feature_names) + absent_features = _absent_uniform_features(normalized, feature_names, slot_names) - constant_features = {"relation": relation} - ignored_prefix_tokens = _ignored_prefix_tokens(normalized, "agent") - pattern: tuple[PatternElement, ...] = ( - TypedSlot( - feature_name="agent", - slot_type=_slot_type(normalized, "agent"), - min_width=min(prefix_widths), - max_width=max(prefix_widths), - ignored_prefix_tokens=ignored_prefix_tokens, - ), - Constant(relation_token), - TypedSlot(feature_name="count", slot_type=_slot_type(normalized, "count")), - TypedSlot(feature_name="unit", slot_type=_slot_type(normalized, "unit")), - ) - return DerivedRecognizer( - pattern=pattern, - teaching_set_id=teaching_set_id, - constant_features=constant_features, - absent_features=absent_features, - ) + relation = _uniform_feature_value(normalized, "relation") + relation_token = str(relation) + anchors = tuple(_single_token_index(tokens, relation_token) for tokens, _bundle in normalized) + + prefix_widths = tuple(anchor for anchor in anchors) + suffix_widths = tuple(len(tokens) - anchor - 1 for (tokens, _bundle), anchor in zip(normalized, anchors)) + if min(prefix_widths) < 1: + raise ValueError("agent slot must contain at least one token") + if set(suffix_widths) != {2}: + raise ValueError("Phase 1 expects count and unit slots after the relation anchor") + + constant_features = {"relation": relation} + ignored_prefix_tokens = _ignored_prefix_tokens(normalized, "agent") + pattern: tuple[PatternElement, ...] = ( + TypedSlot( + feature_name="agent", + slot_type=_slot_type(normalized, "agent"), + min_width=min(prefix_widths), + max_width=max(prefix_widths), + ignored_prefix_tokens=ignored_prefix_tokens, + ), + Constant(relation_token), + TypedSlot(feature_name="count", slot_type=_slot_type(normalized, "count")), + TypedSlot(feature_name="unit", slot_type=_slot_type(normalized, "unit")), + ) + return DerivedRecognizer( + pattern=pattern, + teaching_set_id=teaching_set_id, + constant_features=constant_features, + absent_features=absent_features, + ) + else: + # Phase 2 logic + slot_names = frozenset({"agent", "relation", "count", "unit"}) + absent_features = _absent_uniform_features(normalized, feature_names, slot_names) + + vp_list = sorted(list(unique_vps)) + constant_features = {"__allowed_verbs": "|".join(vp_list)} + + prefix_widths = [] + for tokens, bundle in normalized: + agent_feat = bundle.get("agent") + if agent_feat is not None and isinstance(agent_feat.evidence, EvidenceSpan): + prefix_widths.append(agent_feat.evidence.end) + + ignored_prefix_tokens = _ignored_prefix_tokens(normalized, "agent") + max_verb_width = max(len(vp.split()) for vp in vp_list) + + pattern = ( + TypedSlot( + feature_name="agent", + slot_type="str", + min_width=1, + max_width=max(prefix_widths), + ignored_prefix_tokens=ignored_prefix_tokens, + ), + TypedSlot( + feature_name="relation", + slot_type="str", + min_width=1, + max_width=max_verb_width, + ), + TypedSlot( + feature_name="count", + slot_type="int", + min_width=1, + max_width=1, + ), + TypedSlot( + feature_name="unit", + slot_type="str", + min_width=1, + max_width=1, + ), + ) + + return DerivedRecognizer( + pattern=pattern, + teaching_set_id=teaching_set_id, + constant_features=constant_features, + absent_features=absent_features, + ) def recognize(recognizer: DerivedRecognizer, token_sequence: TokenSequence) -> RecognitionOutcome: tokens = tuple(token_sequence) + + # If this is Phase 1 (no __allowed_verbs in constant_features), run Phase 1 logic + if "__allowed_verbs" not in recognizer.constant_features: + provenance = RecognitionProvenance( + mechanism="anti_unification", + teaching_set_id=recognizer.teaching_set_id, + resolution_level="chunk", + replay_seed="", + ) + matches = _match_pattern(recognizer.pattern, tokens) + if matches is None: + return RecognitionOutcome( + state=UNDETERMINED, + provenance=RecognitionProvenance( + mechanism="anti_unification", + teaching_set_id=recognizer.teaching_set_id, + resolution_level="shape", + replay_seed="", + ), + refusal_reason=ShapeRefusal( + reason=f"shape_mismatch:{_shape_description(recognizer.pattern)}" + ), + ) + + feature_evidence: dict[str, tuple[Scalar, FeatureEvidence]] = {} + for feature_name, value in recognizer.constant_features.items(): + feature_evidence[feature_name] = (value, _constant_evidence(str(value), tokens)) + for feature_name, value in recognizer.absent_features.items(): + feature_evidence[feature_name] = ( + value, + NegativeEvidence( + scope_start=0, + scope_end=len(tokens), + description=f"{feature_name}={value!r} evidenced by absence of taught counter-marker", + ), + ) + for slot, span in matches.items(): + value, evidence = _lift_slot(slot, tokens, span) + feature_evidence[slot.feature_name] = (value, evidence) + + proposition = FeatureBundle.from_mapping(feature_evidence) + return RecognitionOutcome( + state=EVIDENCED, + provenance=provenance, + proposition=proposition, + refusal_reason=None, + ) + + # Phase 2 logic provenance = RecognitionProvenance( mechanism="anti_unification", teaching_set_id=recognizer.teaching_set_id, resolution_level="chunk", replay_seed="", ) - matches = _match_pattern(recognizer.pattern, tokens) - if matches is None: + + allowed_vps = recognizer.constant_features["__allowed_verbs"].split("|") + # Sort by token length descending, then alphabetically for deterministic precedence + allowed_vps_sorted = sorted(allowed_vps, key=lambda x: (-len(x.split()), x)) + + verb_match = None + for vp_str in allowed_vps_sorted: + vp_tokens = tuple(vp_str.split()) + n_vp = len(vp_tokens) + # Search starting from index 1 to ensure at least 1 agent token exists + for i in range(1, len(tokens) - n_vp + 1): + if tokens[i : i + n_vp] == vp_tokens: + verb_match = (i, i + n_vp, vp_str) + break + if verb_match is not None: + break + + if verb_match is None: return RecognitionOutcome( state=UNDETERMINED, provenance=RecognitionProvenance( @@ -149,21 +283,156 @@ def recognize(recognizer: DerivedRecognizer, token_sequence: TokenSequence) -> R ), ) - feature_evidence: dict[str, tuple[Scalar, FeatureEvidence]] = {} - for feature_name, value in recognizer.constant_features.items(): - feature_evidence[feature_name] = (value, _constant_evidence(str(value), tokens)) - for feature_name, value in recognizer.absent_features.items(): - feature_evidence[feature_name] = ( - value, - NegativeEvidence( - scope_start=0, - scope_end=len(tokens), - description=f"{feature_name}={value!r} evidenced by absence of taught counter-marker", + verb_start, verb_end, vp_str = verb_match + + # Agent validation + agent_start = 0 + if verb_start > 1 and tokens[0].lower() in {"a", "the"}: + agent_start = 1 + + agent_tokens = tokens[agent_start:verb_start] + if not agent_tokens: + return RecognitionOutcome( + state=UNDETERMINED, + provenance=RecognitionProvenance( + mechanism="anti_unification", + teaching_set_id=recognizer.teaching_set_id, + resolution_level="shape", + replay_seed="", + ), + refusal_reason=ShapeRefusal( + reason=f"shape_mismatch:{_shape_description(recognizer.pattern)}" ), ) - for slot, span in matches.items(): - value, evidence = _lift_slot(slot, tokens, span) - feature_evidence[slot.feature_name] = (value, evidence) + + agent_value = " ".join(agent_tokens) + agent_span = EvidenceSpan(agent_start, verb_start, agent_value) + + # Scan for digit/number tokens in the suffix + digits: list[int] = [] + digit_spans: list[EvidenceSpan] = [] + for i in range(verb_end, len(tokens)): + if tokens[i].isdigit(): + digits.append(int(tokens[i])) + digit_spans.append(EvidenceSpan(i, i + 1, tokens[i])) + + # Layer 2 refusal: missing count + if not digits: + return RecognitionOutcome( + state=UNDETERMINED, + provenance=RecognitionProvenance( + mechanism="anti_unification", + teaching_set_id=recognizer.teaching_set_id, + resolution_level="word", + replay_seed="", + ), + refusal_reason=FeatureEvidenceRefusal( + missing_feature="count", + reason="missing count feature evidence span", + ), + ) + + # Layer 3 refusal: count contradiction + if len(set(digits)) > 1: + return RecognitionOutcome( + state=CONTRADICTED, + provenance=provenance, + refusal_reason=FeatureConsistencyRefusal( + feature="count", + reason="contradictory values for count", + spans=tuple(digit_spans), + ), + ) + + # Validate remaining tokens structure (must be [Count, Unit] or [Count, Unit, "and", Count, Unit]) + count_index = digit_spans[0].start + is_valid_structure = False + if len(tokens) == count_index + 2: + is_valid_structure = True + elif len(tokens) == count_index + 5 and tokens[count_index + 2] == "and": + if tokens[count_index + 3].isdigit(): + is_valid_structure = True + + if not is_valid_structure: + return RecognitionOutcome( + state=UNDETERMINED, + provenance=RecognitionProvenance( + mechanism="anti_unification", + teaching_set_id=recognizer.teaching_set_id, + resolution_level="shape", + replay_seed="", + ), + refusal_reason=ShapeRefusal( + reason=f"shape_mismatch:{_shape_description(recognizer.pattern)}" + ), + ) + + # Lift features + count_val = digits[0] + count_span = digit_spans[0] + + unit_token = tokens[count_index + 1] + unit_val = _singularize(unit_token) + unit_span = EvidenceSpan(count_index + 1, count_index + 2, unit_token) + + relation_val = tokens[verb_end - 1] + relation_span = EvidenceSpan(verb_end - 1, verb_end, relation_val) + + # Tense + first_verb_token = tokens[verb_start] + if first_verb_token == "had": + tense_val = "past" + elif first_verb_token == "will": + tense_val = "future" + else: + tense_val = "present" + tense_span = EvidenceSpan(verb_start, verb_start + 1, first_verb_token) + + # Polarity + if "not" in tokens[verb_start:verb_end]: + polarity_val = "-" + not_idx = tokens[verb_start:verb_end].index("not") + verb_start + polarity_span = EvidenceSpan(not_idx, not_idx + 1, "not") + else: + polarity_val = "+" + polarity_span = NegativeEvidence(0, len(tokens), "no negator present") + + # Modality + if "may" in tokens[verb_start:verb_end]: + modality_val = "possibility" + may_idx = tokens[verb_start:verb_end].index("may") + verb_start + modality_span = EvidenceSpan(may_idx, may_idx + 1, "may") + else: + modality_val = "actual" + modality_span = NegativeEvidence(0, len(tokens), "no modal counter-marker present") + + # Intentionality + intentionality_val = "possession" + intentionality_text = " ".join(tokens[agent_start:verb_end]) + intentionality_span = EvidenceSpan(agent_start, verb_end, intentionality_text) + + feature_evidence: dict[str, tuple[Scalar, FeatureEvidence]] = { + "agent": (agent_value, agent_span), + "count": (count_val, count_span), + "unit": (unit_val, unit_span), + "relation": (relation_val, relation_span), + "tense": (tense_val, tense_span), + "polarity": (polarity_val, polarity_span), + "modality": (modality_val, modality_span), + "intentionality": (intentionality_val, intentionality_span), + } + + # Add other absent uniform features if they are not overridden + for feature_name, value in recognizer.absent_features.items(): + if feature_name not in feature_evidence: + feature_evidence[feature_name] = ( + value, + NegativeEvidence( + scope_start=0, + scope_end=len(tokens), + description=f"{feature_name}={value!r} evidenced by absence of taught counter-marker", + ), + ) proposition = FeatureBundle.from_mapping(feature_evidence) return RecognitionOutcome( diff --git a/tests/test_recognition_phase2.py b/tests/test_recognition_phase2.py new file mode 100644 index 00000000..afa7be62 --- /dev/null +++ b/tests/test_recognition_phase2.py @@ -0,0 +1,249 @@ +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