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:
parent
35c8a1c56b
commit
5f3b7e316d
3 changed files with 526 additions and 0 deletions
158
docs/decisions/ADR-0143-recognition-spike-anti-unification.md
Normal file
158
docs/decisions/ADR-0143-recognition-spike-anti-unification.md
Normal 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
1
recognition/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Teaching-derived structural recognition — ADR-0143."""
|
||||
367
recognition/outcome.py
Normal file
367
recognition/outcome.py
Normal 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",
|
||||
]
|
||||
Loading…
Reference in a new issue