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:
parent
5152719613
commit
81718a0952
5 changed files with 247 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
146
tests/test_adr_0149_recognizer_pipeline_wiring.py
Normal file
146
tests/test_adr_0149_recognizer_pipeline_wiring.py
Normal 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()
|
||||
Loading…
Reference in a new issue