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.
This commit is contained in:
Shay 2026-05-24 12:56:00 -07:00 committed by GitHub
parent a2980bdca2
commit 23ce6f9a06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 564 additions and 46 deletions

View file

@ -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(

View file

@ -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