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.
This commit is contained in:
Shay 2026-05-24 12:37:25 -07:00 committed by GitHub
parent 35c8a1c56b
commit 5f3b7e316d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 526 additions and 0 deletions

View file

@ -0,0 +1,158 @@
# ADR-0143: Teaching-Derived Structural Recognition via Multi-Resolution Anti-Unification
**Status:** Accepted
**Date:** 2026-05-24
**Scope doc:** [teaching-derived-recognition-scope](./teaching-derived-recognition-scope.md)
**Related:** ADR-0142 (epistemic state taxonomy), ADR-0144 (PropositionGraph — integration gate)
---
## Context
CORE's recognition path currently uses hand-coded regex patterns that the engine
neither derived nor can introspect. These patterns store finds rather than
teaching finding. The thesis requires the engine's capacity to recognize
proposition structure to emerge from reviewed teaching examples — not from
hand-authored scaffolding.
The recognition scope document (v2, 2026-05-24) evaluated four candidate
mechanisms and selected **Mechanism D: multi-resolution anti-unification over
token sequences** as the only option that is simultaneously deterministic,
exact, structural, introspectable, and well-defined on token sequences. The
scope committed the spike to a two-phase acceptance test.
This ADR records the decision to implement Mechanism D and defines the output
contract that all recognition work must conform to.
## Decision
**Adopt multi-resolution anti-unification over token sequences as the
recognition mechanism.**
The recognizer is derived deterministically from a reviewed teaching example
set. It operates at two resolutions:
1. **Chunk level** — anti-unify at noun-phrase / verb-phrase / quantifier-phrase
chunks. If every chunk resolves with complete feature evidence, emit the
bundle.
2. **Word-level fallback** — for any chunk that fails at chunk level, drop to
word-by-word anti-unification on just that chunk and attempt to lift the
relevant feature slot.
At both resolutions, the recognizer refuses rather than guesses when evidence
is absent or contradictory. Refusal is the primary signal for what to learn
next.
## Output contract
Every recognition output is a `RecognitionOutcome` (defined in
`recognition/outcome.py`). The contract is frozen; implementers must not
add alternative output types.
```
RecognitionOutcome:
state: EVIDENCED | UNDETERMINED | CONTRADICTED | AMBIGUOUS
proposition: FeatureBundle | None
refusal_reason: ShapeRefusal | FeatureEvidenceRefusal | FeatureConsistencyRefusal | None
provenance: RecognitionProvenance
```
**Invariants:**
- `state == EVIDENCED``proposition` is a complete `FeatureBundle` with
evidence on every feature; `refusal_reason` is `None`.
- Any refusal state → `proposition` is `None`; `refusal_reason` is a typed
instance naming exactly what is missing or contradictory.
- `provenance` is always present. It carries `mechanism`, `teaching_set_id`
(SHA-256 of the canonical example set), and `resolution_level`.
**Epistemic states emitted.** Recognition produces only this subset of the
ADR-0142 taxonomy: EVIDENCED (admitted), UNDETERMINED (shape refused),
CONTRADICTED (feature contradiction), AMBIGUOUS (unresolvable ambiguity).
VERIFIED and DECODED are downstream of substrate cross-reference work and are
never emitted by the recognizer itself.
## Three-layer refusal
| Layer | Class | Trigger |
|---|---|---|
| 1 — Shape | `ShapeRefusal` | Input does not match any derived pattern |
| 2 — Feature evidence | `FeatureEvidenceRefusal` | Shape matched; a required feature has no evidence span |
| 3 — Feature consistency | `FeatureConsistencyRefusal` | Two evidence spans contradict each other on the same feature |
Every layer produces a deterministic, typed, introspectable refusal. The engine
does not approximate or default — it points at exactly which substrate is
missing.
## Feature bundle requirements
Every `BoundFeature` in an admitted bundle carries:
- `name`: the feature dimension (agent, relation, count, unit, polarity,
modality, tense, intentionality, ...)
- `value`: the typed feature value (str | int | float)
- `evidence`: an `EvidenceSpan` (token indices + verbatim text) or a
`NegativeEvidence` record (for features established by absence, e.g.
`polarity=affirmative` from the absence of a negator)
No silent defaults. If a feature cannot be evidenced, the recognizer refuses
at Layer 2.
## Determinism requirements
- `derive_recognizer(examples)` → byte-identical `DerivedRecognizer` on the
same input across runs.
- `recognize(recognizer, tokens)` → byte-identical `RecognitionOutcome` on
the same recognizer and input across runs.
- `DerivedRecognizer` must be serializable to/from JSON for replay.
- `teaching_set_id` is SHA-256 of the sorted canonical example token sequences;
it must be byte-identical across runs on the same examples.
## Acceptance test (two-phase spike)
### Phase 1 — Mechanism on uniform examples
Four teaching examples (all `has`-relation, all affirmative, all actual-
modality — see scope doc). Derived recognizer must:
1. Admit `"A baker has 24 loaves"` with full feature bundle and evidence spans.
2. Refuse `"John gave 5 apples to Mary"` with `ShapeRefusal` (Layer 1).
3. Produce byte-identical output on both cases across two runs.
4. Every feature in the admitted bundle has non-None evidence.
Phase 1 pass → Phase 2 is warranted. Phase 1 fail → mechanism is wrong.
### Phase 2 — Variation lifting and adversarial robustness
Eight teaching examples (varying polarity / modality / tense / intentionality).
Derived recognizer must:
1. Admit three positive variation cases with correct feature bundles.
2. Refuse five adversarial cases at the correct refusal layer (Layer 2 or 3).
3. Produce byte-identical output across two runs.
Full test cases in scope doc.
## What this ADR does NOT commit
- **Storage layer.** Where derived recognizers live (pack / vault / substrate
state) is deferred to the ADR that follows the spike.
- **Integration into Engine A.** Gated on ADR-0144 (PropositionGraph). Until
then, the recognizer is a standalone module.
- **Parsing framework.** Token-sequence anti-unification is the starting point;
syntactic parse trees are a fallback if token-level fails.
- **Counter-evidence vocabulary.** `"Alleged"`, `"claimed"`, `"(this is a lie)"`
are refused at Layer 2/3 on first encounter. Teaching-loop consumption of
those refusals as correction candidates is its own future scope.
- **Lens-conditional recognition.** How different anchor lenses interact with
derived recognizers is deferred.
## Consequences
- All future recognition work targets `RecognitionOutcome`. No alternative
output contract is permitted without a new ADR.
- Refusal is first-class. Every refusal carries a typed reason consumable by
the teaching loop. Silent failure is a bug.
- The recognizer is not a classifier. It does not assign a proposition type
directly — type emerges from the feature bundle via a downstream mapping
that is itself derived from teaching.
- Integration into the runtime is gated on ADR-0144. Until then, the spike
lives in `recognition/` as a standalone testable module.

1
recognition/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Teaching-derived structural recognition — ADR-0143."""

367
recognition/outcome.py Normal file
View file

@ -0,0 +1,367 @@
"""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",
]