core/recognition/outcome.py
Shay 5f3b7e316d
feat(recognition): output contract + ADR-0143 (#225)
Adds recognition/outcome.py: RecognitionOutcome, FeatureBundle,
BoundFeature, EvidenceSpan, NegativeEvidence, the three typed refusal
classes (ShapeRefusal, FeatureEvidenceRefusal, FeatureConsistencyRefusal),
and RecognitionProvenance. Frozen dataclasses, JSON-serializable,
byte-deterministic invariants enforced in __post_init__.

ADR-0143 commits to Mechanism D (multi-resolution anti-unification over
token sequences) and defines the two-phase acceptance test.
2026-05-24 12:37:25 -07:00

367 lines
12 KiB
Python

"""RecognitionOutcome and all supporting types for teaching-derived recognition.
ADR-0143: the recognizer produces exactly one of four epistemic states —
EVIDENCED (admitted with full feature bundle), UNDETERMINED (shape refused),
CONTRADICTED (feature contradiction), AMBIGUOUS (unresolvable ambiguity).
VERIFIED and DECODED are downstream of substrate cross-reference work and
are never emitted here.
Every admitted bundle carries evidence on every feature. No silent defaults.
Every refusal carries a typed reason naming exactly what is missing or wrong.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# ---------------------------------------------------------------------------
# Evidence
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class EvidenceSpan:
"""A contiguous span in the input token sequence that evidences a feature.
``start`` and ``end`` are token indices (half-open, i.e. tokens[start:end]).
``text`` is the verbatim text of that span for audit display; it is
informational only and must not be used for matching logic.
"""
start: int
end: int
text: str
def __post_init__(self) -> None:
if self.start < 0:
raise ValueError(f"EvidenceSpan.start must be >= 0, got {self.start}")
if self.end <= self.start:
raise ValueError(
f"EvidenceSpan.end must be > start, got start={self.start} end={self.end}"
)
def as_dict(self) -> dict[str, Any]:
return {"start": self.start, "end": self.end, "text": self.text}
@dataclass(frozen=True, slots=True)
class NegativeEvidence:
"""Evidence derived from the *absence* of a token or marker in the input.
Used for features like ``polarity=affirmative`` which are established by
the absence of a negator rather than the presence of a positive marker.
``scope`` names the span over which the absence was confirmed (the full
input token range by default); ``description`` is a human-readable
explanation for audit.
"""
scope_start: int
scope_end: int
description: str
def as_dict(self) -> dict[str, Any]:
return {
"scope_start": self.scope_start,
"scope_end": self.scope_end,
"description": self.description,
}
# A feature evidence is either a positive span or a negative-evidence record.
FeatureEvidence = EvidenceSpan | NegativeEvidence
# ---------------------------------------------------------------------------
# Feature bundle
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class BoundFeature:
"""A single feature in a recognized bundle: value + evidence.
``value`` is the typed feature value (str, int, float — never None on an
admitted bundle). ``evidence`` is the span or negative-evidence record
that supports this value. Both are required; there are no silent defaults.
"""
name: str
value: str | int | float
evidence: FeatureEvidence
def as_dict(self) -> dict[str, Any]:
ev = (
self.evidence.as_dict()
if isinstance(self.evidence, (EvidenceSpan, NegativeEvidence))
else {}
)
ev_type = (
"span" if isinstance(self.evidence, EvidenceSpan) else "negative"
)
return {
"name": self.name,
"value": self.value,
"evidence": ev,
"evidence_type": ev_type,
}
@dataclass(frozen=True, slots=True)
class FeatureBundle:
"""A complete set of bound features for a recognized proposition.
``features`` is a tuple of ``BoundFeature`` in canonical (sorted-by-name)
order. Canonical order ensures byte-identical serialization regardless
of the order in which features were lifted during recognition.
A bundle is only emitted when every expected feature slot is filled.
Partial bundles must not be returned — the recognizer must either complete
the bundle or refuse with a typed reason.
"""
features: tuple[BoundFeature, ...]
def __post_init__(self) -> None:
names = [f.name for f in self.features]
if len(names) != len(set(names)):
raise ValueError(f"FeatureBundle has duplicate feature names: {names}")
# Enforce canonical order.
expected = sorted(names)
if names != expected:
raise ValueError(
f"FeatureBundle.features must be in sorted-by-name order. "
f"Got {names}, expected {expected}."
)
def get(self, name: str) -> BoundFeature | None:
for f in self.features:
if f.name == name:
return f
return None
def as_dict(self) -> dict[str, Any]:
return {"features": [f.as_dict() for f in self.features]}
@classmethod
def from_mapping(
cls, mapping: dict[str, tuple[str | int | float, FeatureEvidence]]
) -> "FeatureBundle":
"""Convenience constructor: {name: (value, evidence)} → FeatureBundle.
Sorts features by name to guarantee canonical order.
"""
features = tuple(
BoundFeature(name=k, value=v, evidence=ev)
for k, (v, ev) in sorted(mapping.items())
)
return cls(features=features)
# ---------------------------------------------------------------------------
# Typed refusal reasons
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ShapeRefusal:
"""Layer 1 refusal: input token sequence does not match any derived pattern.
``nearest_patterns`` is an optional tuple of (teaching_set_id, distance)
pairs for the closest patterns the recognizer tried — informational, not
load-bearing.
"""
reason: str
nearest_patterns: tuple[tuple[str, float], ...] = ()
def as_dict(self) -> dict[str, Any]:
return {
"layer": 1,
"type": "shape",
"reason": self.reason,
"nearest_patterns": list(self.nearest_patterns),
}
@dataclass(frozen=True, slots=True)
class FeatureEvidenceRefusal:
"""Layer 2 refusal: shape matched but a required feature has no evidence span.
``missing_feature`` names the feature slot that could not be filled.
``unrecognized_token`` is the token (if any) that was present but not in
the decoded vocabulary for this feature — useful for teaching targeting.
"""
missing_feature: str
reason: str
unrecognized_token: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"layer": 2,
"type": "feature_evidence",
"missing_feature": self.missing_feature,
"reason": self.reason,
"unrecognized_token": self.unrecognized_token,
}
@dataclass(frozen=True, slots=True)
class FeatureConsistencyRefusal:
"""Layer 3 refusal: two evidence spans contradict each other on the same feature.
``feature`` names the feature where contradiction was detected.
``spans`` lists the conflicting evidence spans (at least two).
"""
feature: str
reason: str
spans: tuple[EvidenceSpan, ...]
def as_dict(self) -> dict[str, Any]:
return {
"layer": 3,
"type": "feature_consistency",
"feature": self.feature,
"reason": self.reason,
"spans": [s.as_dict() for s in self.spans],
}
RefusalReason = ShapeRefusal | FeatureEvidenceRefusal | FeatureConsistencyRefusal
# ---------------------------------------------------------------------------
# Provenance
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class RecognitionProvenance:
"""Structured provenance record for a RecognitionOutcome.
Every output — admitted or refused — carries provenance so it can be
replayed, audited, and targeted by the teaching loop.
Fields:
mechanism : always "anti_unification" for ADR-0143 outputs.
teaching_set_id : SHA-256 of the canonical teaching example set used
to derive the recognizer. Byte-identical across runs
on the same examples (determinism guarantee).
resolution_level : "chunk" if chunk-level anti-unification succeeded;
"word" if word-level fallback was used; "shape" if
refused at shape level before feature lifting.
replay_seed : reserved for future use; empty string for Phase 1.
"""
mechanism: str
teaching_set_id: str
resolution_level: str
replay_seed: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"mechanism": self.mechanism,
"teaching_set_id": self.teaching_set_id,
"resolution_level": self.resolution_level,
"replay_seed": self.replay_seed,
}
# ---------------------------------------------------------------------------
# RecognitionOutcome
# ---------------------------------------------------------------------------
# Epistemic states the recognizer may emit (subset of the 14-state taxonomy).
# VERIFIED and DECODED are downstream of substrate cross-reference work and
# are never emitted by the recognizer itself.
EVIDENCED = "evidenced"
CONTRADICTED = "contradicted"
AMBIGUOUS = "ambiguous"
UNDETERMINED = "undetermined"
_VALID_STATES = frozenset({EVIDENCED, CONTRADICTED, AMBIGUOUS, UNDETERMINED})
@dataclass(frozen=True, slots=True)
class RecognitionOutcome:
"""The canonical output of the teaching-derived recognizer.
Invariants:
- If ``state == EVIDENCED``: ``proposition`` is a complete FeatureBundle
with evidence on every feature; ``refusal_reason`` is None.
- If ``state`` is a refusal class (UNDETERMINED / CONTRADICTED /
AMBIGUOUS): ``proposition`` is None; ``refusal_reason`` is a typed
RefusalReason.
- ``provenance`` is always present regardless of state.
- ``state`` is always one of the four values above.
"""
state: str
provenance: RecognitionProvenance
proposition: FeatureBundle | None = None
refusal_reason: RefusalReason | None = None
def __post_init__(self) -> None:
if self.state not in _VALID_STATES:
raise ValueError(
f"RecognitionOutcome.state must be one of {sorted(_VALID_STATES)}, "
f"got {self.state!r}"
)
if self.state == EVIDENCED:
if self.proposition is None:
raise ValueError(
"RecognitionOutcome with state=EVIDENCED must have a proposition."
)
if self.refusal_reason is not None:
raise ValueError(
"RecognitionOutcome with state=EVIDENCED must not have a refusal_reason."
)
else:
if self.proposition is not None:
raise ValueError(
f"RecognitionOutcome with state={self.state} must not have a proposition."
)
if self.refusal_reason is None:
raise ValueError(
f"RecognitionOutcome with state={self.state} must have a refusal_reason."
)
@property
def admitted(self) -> bool:
return self.state == EVIDENCED
@property
def refused(self) -> bool:
return self.state != EVIDENCED
def as_dict(self) -> dict[str, Any]:
return {
"state": self.state,
"provenance": self.provenance.as_dict(),
"proposition": self.proposition.as_dict() if self.proposition else None,
"refusal_reason": (
self.refusal_reason.as_dict() if self.refusal_reason else None
),
}
__all__ = [
"AMBIGUOUS",
"BoundFeature",
"CONTRADICTED",
"EVIDENCED",
"EvidenceSpan",
"FeatureBundle",
"FeatureConsistencyRefusal",
"FeatureEvidence",
"FeatureEvidenceRefusal",
"NegativeEvidence",
"RecognitionOutcome",
"RecognitionProvenance",
"RefusalReason",
"ShapeRefusal",
"UNDETERMINED",
]