From 81718a0952c07eca68f158dbc37baa3576f74718 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 25 May 2026 12:24:48 -0700 Subject: [PATCH] feat(W-007): wire DerivedRecognizer registry into CognitiveTurnPipeline (ADR-0149) (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- chat/runtime.py | 24 +++ core/cognition/pipeline.py | 7 +- ...0149-derived-recognizer-pipeline-wiring.md | 65 ++++++++ recognition/registry.py | 6 + ...est_adr_0149_recognizer_pipeline_wiring.py | 146 ++++++++++++++++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/ADR-0149-derived-recognizer-pipeline-wiring.md create mode 100644 tests/test_adr_0149_recognizer_pipeline_wiring.py diff --git a/chat/runtime.py b/chat/runtime.py index 1841f5f7..d70acbe3 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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() diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index e3c3a31a..561c18d1 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -123,7 +123,12 @@ class CognitiveTurnPipeline: self.runtime = runtime self._last_node_id: str | None = None self.teaching_store = teaching_store or TeachingStore() - self._recognizer = recognizer + 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 diff --git a/docs/decisions/ADR-0149-derived-recognizer-pipeline-wiring.md b/docs/decisions/ADR-0149-derived-recognizer-pipeline-wiring.md new file mode 100644 index 00000000..247af1e9 --- /dev/null +++ b/docs/decisions/ADR-0149-derived-recognizer-pipeline-wiring.md @@ -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. diff --git a/recognition/registry.py b/recognition/registry.py index ba7b0434..c920484b 100644 --- a/recognition/registry.py +++ b/recognition/registry.py @@ -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) diff --git a/tests/test_adr_0149_recognizer_pipeline_wiring.py b/tests/test_adr_0149_recognizer_pipeline_wiring.py new file mode 100644 index 00000000..202db05c --- /dev/null +++ b/tests/test_adr_0149_recognizer_pipeline_wiring.py @@ -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()