feat(W-007): wire DerivedRecognizer registry into CognitiveTurnPipeline (ADR-0149) (#274)

- RecognizerRegistry.first_admitted() — deterministic first-registered selection
- CognitiveTurnPipeline consults runtime registry when no recognizer explicitly passed
- ChatRuntime gains _pending_recognizer_examples + record_recognition_example()
- checkpoint_engine_state() derives and registers recognizer from accumulated examples
- RuntimeConfig.recognition_grounded_graph gate (already existed) controls wiring
- ADR-0149 decision record
This commit is contained in:
Shay 2026-05-25 12:24:48 -07:00 committed by GitHub
parent 5152719613
commit 81718a0952
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 247 additions and 1 deletions

View file

@ -51,6 +51,8 @@ from teaching.discovery import (
)
from teaching.discovery_sink import DiscoveryCandidateSink
from engine_state import EngineStateStore
from recognition.anti_unifier import derive_recognizer
from recognition.outcome import FeatureBundle
from recognition.registry import RecognizerRegistry
from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig
from core.physics.drive import DriveGradientMap, GradientField
@ -634,6 +636,9 @@ class ChatRuntime:
self._recognizer_registry: RecognizerRegistry = RecognizerRegistry()
self._turn_count: int = 0
self._pending_candidates: list[DiscoveryCandidate] = []
self._pending_recognizer_examples: list[
tuple[tuple[str, ...], FeatureBundle]
] = []
if self._engine_state_store is not None and self._engine_state_store.exists():
self._load_engine_state()
@ -652,6 +657,13 @@ class ChatRuntime:
store = self._engine_state_store
if store is None:
return
if (
self.config.recognition_grounded_graph
and self._pending_recognizer_examples
):
recognizer = derive_recognizer(tuple(self._pending_recognizer_examples))
self._recognizer_registry.register(recognizer)
self._pending_recognizer_examples.clear()
store.save_recognizers(self._recognizer_registry.all())
candidates_to_save = self._pending_candidates
if self.config.auto_contemplate and candidates_to_save:
@ -664,6 +676,18 @@ class ChatRuntime:
store.save_discovery_candidates(candidates_to_save)
store.save_manifest(self._turn_count)
def record_recognition_example(
self,
tokens: tuple[str, ...],
bundle: FeatureBundle,
) -> None:
self._pending_recognizer_examples.append((tuple(tokens), bundle))
def first_admitted_recognizer(self):
if not self.config.recognition_grounded_graph:
return None
return self._recognizer_registry.first_admitted()
def _checkpointed_response(self, response: ChatResponse) -> ChatResponse:
self._turn_count += 1
self.checkpoint_engine_state()

View file

@ -123,7 +123,12 @@ class CognitiveTurnPipeline:
self.runtime = runtime
self._last_node_id: str | None = None
self.teaching_store = teaching_store or TeachingStore()
if recognizer is not None:
self._recognizer = recognizer
elif hasattr(runtime, "first_admitted_recognizer"):
self._recognizer = runtime.first_admitted_recognizer()
else:
self._recognizer = None
self._prior_surface: str | None = None
self._turn_number: int = 0
# ADR-0021 §Articulation: subjects of prior SPECULATIVE teaching

View file

@ -0,0 +1,65 @@
# ADR-0149 — Integrate DerivedRecognizer into CognitiveTurnPipeline
**Status:** Accepted
**Date:** 2026-05-25
**Work item:** W-007
---
## Context
ADR-0143 introduced deterministic `DerivedRecognizer` derivation and matching.
ADR-0144 gave `CognitiveTurnPipeline` an epistemic graph carrier and an optional
`recognizer` constructor slot. ADR-0146 added persisted engine state, including
`RecognizerRegistry`. ADR-0148 / W-003 wired vault promotion so `COHERENT`
entries can eventually become recognition evidence.
The missing edge was live admission: the runtime had a registry, but the
pipeline never consulted it. A populated registry was therefore inert unless a
test or caller manually passed a recognizer to `CognitiveTurnPipeline`.
---
## Decision
`RecognizerRegistry.first_admitted()` returns the first registered recognizer in
deterministic insertion order, or `None` when empty.
`ChatRuntime` now exposes `first_admitted_recognizer()`, gated by
`RuntimeConfig.recognition_grounded_graph`. When the flag is false, the method
returns `None` and the turn path is byte-behavior compatible with the previous
runtime.
`CognitiveTurnPipeline` uses an explicitly supplied recognizer first. When no
recognizer is supplied, it asks the runtime for the first admitted recognizer.
If the registry is empty, recognition remains absent and the existing
intent-derived graph path is used.
`ChatRuntime.record_recognition_example(tokens, bundle)` records deterministic
training pairs for test harnesses and future automated collection. At
`checkpoint_engine_state()`, after the vault promotion boundary, a non-empty
pending example set is passed to `derive_recognizer()` and the result is
registered before engine-state persistence.
---
## Null-Drop
`recognition_grounded_graph=False` means the registry is ignored, no recognizer
is passed to the pipeline, and pending examples are not derived at checkpoint.
The default therefore preserves the previous turn behavior.
---
## Follow-Up
Automated example collection is intentionally out of scope. `derive_recognizer()`
requires `(TokenSequence, FeatureBundle)` pairs with span-level evidence, not
teaching corpus templates. The follow-up path is:
```text
finalize_turn -> FeatureBundle with evidence spans -> record_recognition_example -> checkpoint derivation
```
That follow-up makes the registry self-populating; this ADR makes the registry
live and replay-persistent.

View file

@ -23,6 +23,12 @@ class RecognizerRegistry:
def all(self) -> list[DerivedRecognizer]:
return list(self._registry.values())
def first_admitted(self) -> DerivedRecognizer | None:
"""Return the first registered recognizer, or None if registry is empty."""
if not self._registry:
return None
return next(iter(self._registry.values()))
def __len__(self) -> int:
return len(self._registry)

View file

@ -0,0 +1,146 @@
from __future__ import annotations
from dataclasses import replace
from chat.runtime import ChatRuntime
from core.cognition import CognitiveTurnPipeline
from core.config import DEFAULT_CONFIG
from engine_state import EngineStateStore
from recognition.anti_unifier import derive_recognizer
from recognition.outcome import EvidenceSpan, FeatureBundle, NegativeEvidence
from recognition.registry import RecognizerRegistry
def _config(*, recognition_grounded_graph: bool):
return replace(
DEFAULT_CONFIG,
recognition_grounded_graph=recognition_grounded_graph,
)
def _span(tokens: tuple[str, ...], start: int, end: int) -> EvidenceSpan:
return EvidenceSpan(start=start, end=end, text=" ".join(tokens[start:end]))
def _bundle(
tokens: tuple[str, ...],
agent_span: tuple[int, int],
count_span: tuple[int, int],
unit_span: tuple[int, int],
agent: str,
count: int,
unit: str,
) -> FeatureBundle:
return FeatureBundle.from_mapping(
{
"agent": (agent, _span(tokens, *agent_span)),
"count": (count, _span(tokens, *count_span)),
"intentionality": (
"possession",
_span(
tokens,
1 if tokens[0] in {"A", "The"} else 0,
3 if tokens[0] in {"A", "The"} else 2,
),
),
"modality": (
"actual",
NegativeEvidence(0, len(tokens), "no modal counter-marker present"),
),
"polarity": ("+", NegativeEvidence(0, len(tokens), "no negator present")),
"relation": ("has", _span(tokens, count_span[0] - 1, count_span[0])),
"tense": ("present", _span(tokens, count_span[0] - 1, count_span[0])),
"unit": (unit, _span(tokens, *unit_span)),
}
)
def _examples() -> list[tuple[tuple[str, ...], FeatureBundle]]:
john = ("John", "has", "5", "apples")
mary = ("Mary", "has", "3", "books")
school = ("A", "school", "has", "100", "students")
library = ("The", "library", "has", "12", "chairs")
return [
(john, _bundle(john, (0, 1), (2, 3), (3, 4), "John", 5, "apple")),
(mary, _bundle(mary, (0, 1), (2, 3), (3, 4), "Mary", 3, "book")),
(
school,
_bundle(school, (1, 2), (3, 4), (4, 5), "school", 100, "student"),
),
(
library,
_bundle(library, (1, 2), (3, 4), (4, 5), "library", 12, "chair"),
),
]
def _recognizer():
return derive_recognizer(_examples())
def test_registry_empty_no_recognizer_passed(tmp_path) -> None:
runtime = ChatRuntime(
config=_config(recognition_grounded_graph=True),
engine_state_path=tmp_path,
)
result = CognitiveTurnPipeline(runtime).run("A baker has 24 loaves", max_tokens=4)
assert result.epistemic_graph is None
def test_registry_with_recognizer_wires_into_pipeline(tmp_path) -> None:
runtime = ChatRuntime(
config=_config(recognition_grounded_graph=True),
engine_state_path=tmp_path,
)
recognizer = _recognizer()
runtime._recognizer_registry.register(recognizer)
result = CognitiveTurnPipeline(runtime).run("A baker has 24 loaves", max_tokens=4)
assert result.epistemic_graph is not None
assert result.epistemic_graph.recognizer_id == recognizer.teaching_set_id
assert result.epistemic_graph.nodes[0].node_id.startswith(
f"{recognizer.teaching_set_id}:"
)
def test_flag_off_registry_ignored(tmp_path) -> None:
runtime = ChatRuntime(
config=_config(recognition_grounded_graph=False),
engine_state_path=tmp_path,
)
runtime._recognizer_registry.register(_recognizer())
result = CognitiveTurnPipeline(runtime).run("A baker has 24 loaves", max_tokens=4)
assert result.epistemic_graph is None
def test_first_admitted_returns_none_on_empty() -> None:
assert RecognizerRegistry().first_admitted() is None
def test_first_admitted_returns_registered() -> None:
registry = RecognizerRegistry()
recognizer = _recognizer()
registry.register(recognizer)
assert registry.first_admitted() == recognizer
def test_record_and_checkpoint_derives_recognizer(tmp_path) -> None:
runtime = ChatRuntime(
config=_config(recognition_grounded_graph=True),
engine_state_path=tmp_path,
)
for tokens, bundle in _examples():
runtime.record_recognition_example(tokens, bundle)
runtime.checkpoint_engine_state()
assert len(runtime._recognizer_registry) == 1
persisted = EngineStateStore(tmp_path).load_recognizers()
assert persisted == runtime._recognizer_registry.all()