diff --git a/docs/decisions/ADR-0143-recognition-spike-anti-unification.md b/docs/decisions/ADR-0143-recognition-spike-anti-unification.md new file mode 100644 index 00000000..000006cf --- /dev/null +++ b/docs/decisions/ADR-0143-recognition-spike-anti-unification.md @@ -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. diff --git a/recognition/__init__.py b/recognition/__init__.py new file mode 100644 index 00000000..4d949163 --- /dev/null +++ b/recognition/__init__.py @@ -0,0 +1 @@ +"""Teaching-derived structural recognition — ADR-0143.""" diff --git a/recognition/outcome.py b/recognition/outcome.py new file mode 100644 index 00000000..a7b5116a --- /dev/null +++ b/recognition/outcome.py @@ -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", +]