W-007/ADR-0149 wired the consumer side of the recognizer registry (first_admitted_recognizer → graph derivation, opt-in via recognition_grounded_graph). The producer side — capturing (tokens, bundle) from admitted turns so derive_recognizer at checkpoint can anti-unify them — had no production caller. record_recognition_example existed but was only invoked by tests, so _pending_recognizer_examples stayed empty in live sessions and the registry could never grow from traffic. Observed: 103-turn session wrote recognizers.jsonl empty even with recognition running. - CognitiveTurnPipeline.run calls runtime.record_recognition_example at the admitted-recognition boundary - Producer fires unconditionally; consumer (derive_recognizer at checkpoint) stays opt-in behind the same flag — flipping it later is no longer a cold start - hasattr guard keeps the pipeline tolerant of non-ChatRuntime runtimes Validated: tests/test_adr_0154_recognizer_producer_wiring.py (5 tests covering admit/refuse, flag-off producer, end-to-end loop, accumulation); core test --suite cognition/smoke + recognition phase 1/2/refusal-propagation all green. Out of scope: bootstrap of the first recognizer from operator review (substrate-liveness audit scope); bounded growth of the producer queue when consumer flag stays off (future LRU cap).
101 lines
3.9 KiB
Markdown
101 lines
3.9 KiB
Markdown
# ADR-0154 — DerivedRecognizer producer wiring (W-020b)
|
|
|
|
Status: accepted
|
|
Date: 2026-05-25
|
|
|
|
## Context
|
|
|
|
ADR-0149 (W-007) wired the `DerivedRecognizer` registry's **consumer**
|
|
side: `runtime.first_admitted_recognizer()` is read by
|
|
`CognitiveTurnPipeline.__init__` and feeds the optional
|
|
recognition-grounded graph at `pipeline.py` ~line 217 (gated by
|
|
`recognition_grounded_graph`, default off).
|
|
|
|
The **producer** side — capturing `(tokens, bundle)` from admitted
|
|
turns so `derive_recognizer` at checkpoint can anti-unify them into
|
|
tighter recognizers — was never connected in production code.
|
|
`runtime.record_recognition_example` had zero non-test callers:
|
|
|
|
```bash
|
|
$ grep -rn record_recognition_example --include="*.py" | grep -v test
|
|
chat/runtime.py:703: def record_recognition_example(
|
|
```
|
|
|
|
Consequence: `_pending_recognizer_examples` stayed permanently empty,
|
|
so the conditional at `chat/runtime.py:684-691` —
|
|
|
|
```python
|
|
if (
|
|
self.config.recognition_grounded_graph
|
|
and self._pending_recognizer_examples
|
|
):
|
|
recognizer = derive_recognizer(...)
|
|
...
|
|
```
|
|
|
|
— never fired, even with the flag enabled. The registry could only
|
|
grow via tests calling `record_recognition_example` directly.
|
|
Observed symptom: a 103-turn session wrote `recognizers.jsonl` as
|
|
empty even though recognition was running.
|
|
|
|
## Decision
|
|
|
|
In `CognitiveTurnPipeline.run`, at the admitted-recognition boundary
|
|
(directly after `EpistemicGraph` construction), call
|
|
`runtime.record_recognition_example(raw_tokens, _rec_outcome.proposition)`.
|
|
|
|
- **Producer fires unconditionally** when a turn is admitted — the
|
|
bucket is filled regardless of `recognition_grounded_graph`. This
|
|
means flipping the consumer flag later is not a cold start.
|
|
- **Consumer stays opt-in** behind the same flag — no change to
|
|
`checkpoint_engine_state`'s `derive_recognizer` gate.
|
|
- `hasattr` guard on `runtime.record_recognition_example` keeps the
|
|
pipeline tolerant of non-`ChatRuntime` runtimes (test doubles,
|
|
alternative shells).
|
|
|
|
## Invariants
|
|
|
|
- Refused recognition: no producer call (gated inside
|
|
`if _rec_outcome.admitted:`).
|
|
- No attached recognizer: no recognition runs at all, no producer
|
|
call.
|
|
- Per-turn FeatureBundle is the validated proposition emitted by
|
|
`recognize` — no shape massaging in the pipeline.
|
|
- `recognize` is unchanged; `derive_recognizer` is unchanged; trace
|
|
hash bytes are unchanged for any given turn.
|
|
|
|
## Out of scope
|
|
|
|
- **Bootstrap of the very first recognizer.** This ADR closes the
|
|
loop *given* a recognizer is attached. No path in production code
|
|
seeds the first recognizer from operator review or reviewed
|
|
teaching examples; that is a substrate-liveness concern tracked
|
|
separately under the ADR-0143 / substrate-liveness audit family.
|
|
- **Unbounded growth of `_pending_recognizer_examples` when the
|
|
consumer flag stays off.** With flag=False, the producer
|
|
accumulates forever. Acceptable for short sessions; a future
|
|
bound (LRU or cap) should ship before long-running operators
|
|
enable the producer with the consumer off.
|
|
|
|
## Validation
|
|
|
|
`tests/test_adr_0154_recognizer_producer_wiring.py`:
|
|
- admitted turn appends `(tokens, bundle)` to the producer queue
|
|
(flag=False so the queue is not drained at checkpoint)
|
|
- producer fires when consumer flag is off
|
|
- refused turn does not populate the queue
|
|
- end-to-end loop: with flag=True, an admitted turn feeds the
|
|
producer queue, then `checkpoint_engine_state` drains it via
|
|
`derive_recognizer` and registers the result
|
|
- multiple admitted turns accumulate in order
|
|
|
|
CLI lanes: `core test --suite cognition` (120 + 1 skipped),
|
|
`core test --suite smoke` (67), recognition phase 1/2 + refusal
|
|
propagation (25) all green.
|
|
|
|
## Closure
|
|
|
|
After this ADR, the DerivedRecognizer registry can grow from live
|
|
traffic. The remaining gap is bootstrap — getting the first
|
|
recognizer into the registry without test-only injection. That is a
|
|
substrate-liveness scope concern, not W-020b.
|