feat(recognition): ADR-0144 — EpistemicGraph carrier + pipeline integration (#227)

Implements the PropositionGraph epistemic carrier (ADR-0144):

recognition/carrier.py — EpistemicTransition, EpistemicNode, EpistemicGraph.
  Frozen, JSON-serializable, byte-deterministic. EpistemicNode wraps a
  RecognitionOutcome with an append-only provenance chain; epistemic_state
  property tracks last transition's to_state or outcome.state when empty.

recognition/connector.py — epistemic_node_to_graph_node(). Maps an admitted
  EpistemicNode's FeatureBundle (agent/relation/count/unit) to a GraphNode
  for the generation-side articulation planner.

CognitiveTurnPipeline gains a recognizer: DerivedRecognizer | None param
  (default None — all existing callers unaffected). When attached, run()
  calls recognize() at the top of every turn and wraps admitted outcomes in
  an EpistemicGraph. CognitiveTurnResult.epistemic_graph carries it.

RuntimeConfig.recognition_grounded_graph: bool = False — opt-in flag that
  replaces the intent-derived PropositionGraph with one derived from the
  admitted EpistemicNode via the connector.

RatificationOutcome gains three specific PASSTHROUGH sub-values
  (PASSTHROUGH_NO_FIELD / NO_VOCAB / NO_VERSOR) for _ratify_intent
  observability (ADR-0142 debt 1). All normalise to "passthrough" before
  trace_hash so pre-ADR-0144 hashes are byte-identical.

24/24 acceptance tests pass; 67/67 smoke tests pass; no regressions.
This commit is contained in:
Shay 2026-05-24 13:39:01 -07:00 committed by GitHub
parent 23ce6f9a06
commit 87b0eda345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1476 additions and 13 deletions

View file

@ -29,7 +29,15 @@ from generate.intent_ratifier import (
RatifiedIntent,
ratify_intent,
)
from generate.graph_planner import graph_from_intent, ground_graph, plan_articulation
from generate.graph_planner import (
PropositionGraph,
graph_from_intent,
ground_graph,
plan_articulation,
)
from recognition.anti_unifier import DerivedRecognizer, recognize
from recognition.carrier import EpistemicGraph, EpistemicNode
from recognition.connector import epistemic_node_to_graph_node
from generate.realizer import realize_semantic
from generate.intent import IntentTag
from generate.operators import (
@ -88,6 +96,16 @@ _SUBJECT_STOPWORDS: frozenset[str] = frozenset({
# promotion removes it explicitly.
_MAX_SPECULATIVE_SUBJECTS = 64
# All PASSTHROUGH variants normalised to "passthrough" for trace_hash so
# pre-ADR-0144 hashes remain byte-identical after _ratify_intent gains
# specific sub-values (ADR-0144 / ADR-0142 §Implementation debts, debt 1).
_PASSTHROUGH_OUTCOMES: frozenset[str] = frozenset({
"passthrough",
"passthrough_no_field",
"passthrough_no_vocab",
"passthrough_no_versor",
})
class CognitiveTurnPipeline:
"""Thin pipeline wrapper over ChatRuntime.
@ -96,10 +114,16 @@ class CognitiveTurnPipeline:
a place to plug in. No new intelligence is added here.
"""
def __init__(self, runtime, teaching_store: TeachingStore | None = None) -> None: # runtime: ChatRuntime (no import cycle)
def __init__(
self,
runtime,
teaching_store: TeachingStore | None = None,
recognizer: DerivedRecognizer | None = None,
) -> None: # runtime: ChatRuntime (no import cycle)
self.runtime = runtime
self._last_node_id: str | None = None
self.teaching_store = teaching_store or TeachingStore()
self._recognizer = recognizer
self._prior_surface: str | None = None
self._turn_number: int = 0
# ADR-0021 §Articulation: subjects of prior SPECULATIVE teaching
@ -125,6 +149,25 @@ class CognitiveTurnPipeline:
def run(self, text: str, max_tokens: int | None = None) -> CognitiveTurnResult:
"""Execute one full cognitive turn and return a complete result record."""
# 0. TOKENIZE — once at the top; reused by recognition step and trace.
raw_tokens: tuple[str, ...] = tuple(self.runtime.tokenize(text))
# 0b. RECOGNIZE — if a DerivedRecognizer is attached (ADR-0144).
# Admitted → wrap in EpistemicGraph for observability and optional
# connector-grounded articulation. Refused or absent → None.
epistemic_graph: EpistemicGraph | None = None
if self._recognizer is not None:
_rec_outcome = recognize(self._recognizer, raw_tokens)
if _rec_outcome.admitted:
_ep_node = EpistemicNode(
node_id=f"{self._recognizer.teaching_set_id}:{self._turn_number}",
recognition_outcome=_rec_outcome,
)
epistemic_graph = EpistemicGraph(
nodes=(_ep_node,),
recognizer_id=self._recognizer.teaching_set_id,
)
# 1. LISTEN — capture pre-turn field state
field_state_before: FieldState | None = self._capture_field_state()
@ -155,6 +198,18 @@ class CognitiveTurnPipeline:
graph = graph_from_intent(intent, prior_node_id=prior_node_id)
target = plan_articulation(graph)
# 1b.ii RECOGNITION-GROUNDED GRAPH (ADR-0144, opt-in).
# When recognition admitted and the operator has opted in, replace the
# intent-derived graph and articulation target with ones derived from
# the admitted EpistemicNode via the connector. Default False preserves
# byte-identity for every existing surface and trace_hash.
if self.runtime.config.recognition_grounded_graph and epistemic_graph is not None:
_derived_gn = epistemic_node_to_graph_node(
epistemic_graph.nodes[0], source_intent=intent.tag
)
graph = PropositionGraph().add_node(_derived_gn)
target = plan_articulation(graph)
# 1c. REALIZE — semantic realization from graph + intent.
# Pre-fix (and default today) the realizer fires on the
# ungrounded graph and emits ``<pending>`` / ``...`` surfaces
@ -243,8 +298,7 @@ class CognitiveTurnPipeline:
# 9. Reconstruct input-layer tokens from the turn log
# (turn_log is appended inside chat(); last entry matches this turn)
# When the unknown-domain gate fires, chat() returns a stub without
# appending to turn_log — fall back to the tokenizer.
raw_tokens = tuple(self.runtime.tokenize(text))
# appending to turn_log — fall back to raw_tokens (set at step 0).
if self.runtime.turn_log:
last_turn = self.runtime.turn_log[-1]
filtered_tokens = last_turn.input_tokens
@ -325,7 +379,17 @@ class CognitiveTurnPipeline:
admissibility_trace = response.admissibility_trace
region_was_unconstrained = response.region_was_unconstrained
admissibility_trace_hash = hash_admissibility_trace(admissibility_trace)
ratification_outcome = ratified.outcome.value
# Normalise all PASSTHROUGH sub-values to "passthrough" so the value
# stored in CognitiveTurnResult matches what goes into trace_hash
# (trace_hash_from_result invariant) and pre-ADR-0144 hashes remain
# byte-identical (ADR-0144 / ADR-0142 §Implementation debts, debt 1).
_ratification_outcome_raw = ratified.outcome.value
ratification_outcome = (
"passthrough"
if _ratification_outcome_raw in _PASSTHROUGH_OUTCOMES
else _ratification_outcome_raw
)
_trace_ratification_outcome = ratification_outcome
# ADR-0024 Phase 2 — refusal_reason flows from a future
# materialisation site on ChatResponse. Empty string on every
# non-refused turn; folding into trace_hash is gated on
@ -347,7 +411,7 @@ class CognitiveTurnPipeline:
teaching_epistemic_status=epistemic_status,
operator_invocation=operator_invocation,
admissibility_trace_hash=admissibility_trace_hash,
ratification_outcome=ratification_outcome,
ratification_outcome=_trace_ratification_outcome,
region_was_unconstrained=region_was_unconstrained,
refusal_reason=refusal_reason,
)
@ -378,6 +442,7 @@ class CognitiveTurnPipeline:
ratification_outcome=ratification_outcome,
region_was_unconstrained=region_was_unconstrained,
refusal_reason=refusal_reason,
epistemic_graph=epistemic_graph,
dropped_compound_clauses=dropped_compound_clauses,
versor_condition=response.versor_condition,
trace_hash=trace_hash,
@ -390,14 +455,14 @@ class CognitiveTurnPipeline:
def _ratify_intent(self, intent, field_state):
"""Field-ratify a seeded intent (ADR-0022 §TBD-1).
When no field state or no vocab is available (cold start),
ratification short-circuits to PASSTHROUGH and the seed
survives the existing cold-start behavior is preserved.
Emits specific PASSTHROUGH sub-values (ADR-0144 / ADR-0142 debt 1)
so the trace can distinguish which cold-start condition fired.
All sub-values normalise to "passthrough" for trace_hash.
"""
if field_state is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
outcome=RatificationOutcome.PASSTHROUGH_NO_FIELD,
score=0.0,
threshold=0.0,
seed_tag=intent.tag,
@ -413,7 +478,7 @@ class CognitiveTurnPipeline:
if vocab is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
outcome=RatificationOutcome.PASSTHROUGH_NO_VOCAB,
score=0.0,
threshold=0.0,
seed_tag=intent.tag,
@ -422,7 +487,7 @@ class CognitiveTurnPipeline:
if prompt_versor is None:
return RatifiedIntent(
intent=intent,
outcome=RatificationOutcome.PASSTHROUGH,
outcome=RatificationOutcome.PASSTHROUGH_NO_VERSOR,
score=0.0,
threshold=0.0,
seed_tag=intent.tag,

View file

@ -17,6 +17,7 @@ from generate.graph_planner import ArticulationTarget, PropositionGraph
from generate.intent import DialogueIntent
from generate.proposition import Proposition
from core.physics.identity import IdentityScore
from recognition.carrier import EpistemicGraph
from teaching.correction import CorrectionCandidate
from teaching.review import ReviewedTeachingExample
from teaching.store import PackMutationProposal
@ -100,6 +101,13 @@ class CognitiveTurnResult:
# in place when a future ADR wires the materialisation path.
refusal_reason: str = ""
# --- recognition / epistemic carrier (ADR-0144) ---
# None when no DerivedRecognizer is attached, when recognition refused,
# or on the very first turn before any recognizer is configured.
# Non-None only when recognition admitted (state == EVIDENCED).
# NOT folded into trace_hash in Phase 1 (observability only).
epistemic_graph: EpistemicGraph | None = None
# --- compound intent observability (ADR-0089 Phase C1) ---
# Finding 4 (audit 2026-05-20). ``classify_compound_intent`` returns
# multiple parts for inputs like "What is X and how does it relate

View file

@ -242,6 +242,13 @@ class RuntimeConfig:
# live workload.
unified_ingest: bool = False
# ADR-0144 — recognition-grounded articulation graph. When True and a
# DerivedRecognizer is attached to CognitiveTurnPipeline, the articulation
# graph is derived from the admitted EpistemicNode via the connector rather
# than from intent classification. Default False preserves byte-identity
# for every existing surface and trace_hash.
recognition_grounded_graph: bool = False
DEFAULT_IDENTITY_PACK: str = "default_general_v1"
DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1"

View file

@ -0,0 +1,500 @@
# ADR-0144: PropositionGraph — Epistemic Carrier and Recognition Integration Gate
**Status:** Accepted
**Date:** 2026-05-24
**Scope doc:** [proposition-graph-scope](./proposition-graph-scope.md)
**Related:** ADR-0142 (epistemic state taxonomy), ADR-0143 (recognition spike — anti-unification)
**Unlocks:** Full epistemic provenance wiring (ADR-0142 §What remains gated), recognition integration into Engine A
---
## Context
The recognition spike is complete. `recognition/outcome.py` defines the
frozen output contract; `recognition/anti_unifier.py` implements Phases 1
and 2; 8/8 tests pass across three merged PRs (#225, #224, #226).
ADR-0142 and ADR-0143 both defer their integration work to this ADR, naming
the PropositionGraph as the missing carrier. Two problems block integration:
1. **The name is taken.** `generate/graph_planner.py::PropositionGraph` is
an *articulation planner* — it holds `subject: str`, `predicate: str`,
`obj: str` for generation purposes. That is not the same as a carrier
that holds a `RecognitionOutcome`, an `EpistemicState`, and a provenance
chain across subsystem transitions.
2. **The pipeline has no recognition step.** `CognitiveTurnPipeline.run()`
calls `classify_compound_intent()` to derive intent and builds an
articulation graph from intent labels. It never calls `recognize()`.
The `recognition/` module is entirely disconnected from the cognition
pipeline.
This ADR resolves both problems.
---
## Decision
### Q1 — Carrier structure: two graphs
Adopt **two separate graph types** with distinct responsibilities:
- `generate/graph_planner.py::PropositionGraph` — *articulation planner*
(unchanged). Holds string-level `subject`, `predicate`, `obj` fields
for surface generation. Driven by intent classification. Unmodified.
- `recognition/carrier.py::EpistemicGraph`*epistemic carrier* (new).
Holds `EpistemicNode` records carrying `RecognitionOutcome` + transition
provenance. Driven by `recognize()`. Lives in the `recognition/` module.
A connector function (`recognition/connector.py`) maps an `EpistemicNode`
to a `GraphNode` for callers that need articulation output derived from a
recognized proposition. The connector is present in this ADR; consuming it
in the live generation path is gated on a new `RuntimeConfig` flag
(`recognition_grounded_graph`, default `False`) to preserve byte-identity.
Rationale for separation: the two graphs have different mutation rules.
Articulation fields are set once at planning time and never change.
Epistemic state transitions on every subsystem boundary. Merging them into
one class would require either relaxing the immutability guarantee of
`GraphNode` or introducing update methods that mutate only a subset of
fields — both are worse than a seam.
### Q2 — Session lifetime: per-turn
The `EpistemicGraph` is rebuilt every turn from the `RecognitionOutcome`
emitted by `recognize()`. State from prior turns is not carried forward in
the graph.
Session-persistent graphs (propositions from turn 3 can transition to
VERIFIED in turn 5) require a session home (vault? session context?) that
does not yet exist. That is post-ADR-0144 scope.
### Q3 — Cold-start behavior: no-carrier
When `recognize()` returns a refusal state (`UNDETERMINED`, `CONTRADICTED`,
`AMBIGUOUS`), no `EpistemicGraph` is created.
`CognitiveTurnResult.epistemic_graph` is `None`.
`CognitiveTurnResult.refusal_reason` carries the typed refusal reason as
a string (existing field, already wired in ADR-0024 Phase 2).
When no `DerivedRecognizer` is attached to the pipeline (cold start, or
proposition type outside the current recognizer's teaching set), the
recognition step is skipped entirely. The pipeline behaves byte-identically
to its pre-ADR-0144 state.
---
## Data types
### `EpistemicTransition` — a single state transition with provenance
```python
# recognition/carrier.py
@dataclass(frozen=True, slots=True)
class EpistemicTransition:
"""A single epistemic state transition with its provenance.
``from_state`` and ``to_state`` are values from the ADR-0142 taxonomy
(EVIDENCED, VERIFIED, DECODED, …). ``source`` identifies the
subsystem that caused the transition. ``reason`` is a human-readable
description for audit — not load-bearing for replay.
"""
from_state: str
to_state: str
source: str # e.g. "verifier", "vault", "recognizer"
reason: str
```
### `EpistemicNode` — one proposition with recognition output + history
```python
@dataclass(frozen=True, slots=True)
class EpistemicNode:
"""One recognized proposition with full provenance chain.
``node_id`` is deterministic: the teaching_set_id of the recognizer
used, suffixed with ``:<turn_index>`` (e.g. ``"sha256abc:0"``).
This ensures node IDs are byte-identical across runs on the same
input and recognizer.
``recognition_outcome`` is the frozen ADR-0143 output object carrying
the FeatureBundle (or refusal reason) and RecognitionProvenance.
``transitions`` accumulates provenance as subsystems transition the
state. Empty on construction — the recognizer's emission state is
authoritative until a subsystem adds a transition.
"""
node_id: str
recognition_outcome: RecognitionOutcome
transitions: tuple[EpistemicTransition, ...] = ()
@property
def epistemic_state(self) -> str:
"""Current state: transitions[-1].to_state if any, else outcome.state."""
if self.transitions:
return self.transitions[-1].to_state
return self.recognition_outcome.state
def with_transition(self, transition: EpistemicTransition) -> "EpistemicNode":
"""Return a new node with the transition appended (immutable update)."""
return EpistemicNode(
node_id=self.node_id,
recognition_outcome=self.recognition_outcome,
transitions=(*self.transitions, transition),
)
def as_dict(self) -> dict[str, Any]:
return {
"node_id": self.node_id,
"epistemic_state": self.epistemic_state,
"recognition_outcome": self.recognition_outcome.as_dict(),
"transitions": [t.as_dict() for t in self.transitions],
}
```
### `EpistemicGraph` — the carrier
```python
@dataclass(frozen=True, slots=True)
class EpistemicGraph:
"""Per-turn epistemic carrier for recognized propositions.
``nodes`` is a tuple of EpistemicNodes in recognition order (one per
recognized proposition per turn; ADR-0144 Phase 1 emits exactly one
node per admitted turn).
``recognizer_id`` is the ``teaching_set_id`` of the DerivedRecognizer
used to produce this graph — byte-identical across runs on the same
recognizer and input. Carries replay identity.
"""
nodes: tuple[EpistemicNode, ...]
recognizer_id: str
def get(self, node_id: str) -> EpistemicNode | None:
for node in self.nodes:
if node.node_id == node_id:
return node
return None
def as_dict(self) -> dict[str, Any]:
return {
"recognizer_id": self.recognizer_id,
"nodes": [n.as_dict() for n in self.nodes],
}
def to_json(self) -> str:
return json.dumps(self.as_dict(), ensure_ascii=False,
separators=(",", ":"), sort_keys=True)
```
**Invariants:**
- `EpistemicGraph.to_json()` must be byte-identical across runs on the
same `DerivedRecognizer` and input token sequence.
- Every `EpistemicNode.node_id` within a graph is unique.
- `EpistemicNode.transitions` is append-only. No transition is ever
removed or replaced.
---
## Connector: `EpistemicNode``GraphNode`
```python
# recognition/connector.py
def epistemic_node_to_graph_node(
node: EpistemicNode,
*,
source_intent: IntentTag,
node_id: str | None = None,
) -> GraphNode:
"""Derive a generation-side GraphNode from an admitted EpistemicNode.
Only callable when ``node.recognition_outcome.state == EVIDENCED``.
Raises ``ValueError`` otherwise.
Feature-bundle → GraphNode mapping (v1, has-relation propositions):
subject ← bundle["agent"].value (str)
predicate ← bundle["relation"].value (str)
obj ← f"{bundle['count'].value} {bundle['unit'].value}" (str)
This mapping is intentionally narrow in v1. As the recognizer is
extended to new proposition types, the mapping table grows here.
Unknown feature names raise ``ValueError`` so the gap surfaces
explicitly rather than silently defaulting.
"""
outcome = node.recognition_outcome
if outcome.state != EVIDENCED:
raise ValueError(
f"Cannot derive GraphNode from non-EVIDENCED EpistemicNode: "
f"state={outcome.state!r}"
)
bundle = outcome.proposition
assert bundle is not None # invariant: EVIDENCED → proposition not None
agent = bundle.get("agent")
relation = bundle.get("relation")
count = bundle.get("count")
unit = bundle.get("unit")
subject = str(agent.value) if agent is not None else "<unknown-agent>"
predicate = str(relation.value) if relation is not None else "has"
obj = (
f"{count.value} {unit.value}"
if count is not None and unit is not None
else "<pending>"
)
return GraphNode(
node_id=node_id or node.node_id,
subject=subject,
predicate=predicate,
obj=obj,
source_intent=source_intent,
)
```
---
## Pipeline wiring
### `CognitiveTurnPipeline.__init__` addition
```python
def __init__(
self,
runtime,
teaching_store: TeachingStore | None = None,
recognizer: DerivedRecognizer | None = None, # NEW — default None
) -> None:
...
self._recognizer = recognizer
```
`recognizer=None` is the backward-compatible default. Every existing caller
of `CognitiveTurnPipeline(runtime, ...)` is unaffected.
### Recognition step in `run()`
Insert after `raw_tokens = tuple(self.runtime.tokenize(text))` (which
already exists in `run()` at the bottom of the method) — but the recognition
step needs tokens early. Restructure to tokenize once at the top of `run()`:
```python
def run(self, text: str, max_tokens: int | None = None) -> CognitiveTurnResult:
# 0. TOKENIZE — once at the top; reused by recognition and trace.
raw_tokens: tuple[str, ...] = tuple(self.runtime.tokenize(text))
# 0b. RECOGNIZE — if a DerivedRecognizer is attached.
epistemic_graph: EpistemicGraph | None = None
recognition_refusal_str: str = ""
if self._recognizer is not None:
recognition_outcome = recognize(self._recognizer, raw_tokens)
if recognition_outcome.admitted:
node = EpistemicNode(
node_id=f"{self._recognizer.teaching_set_id}:{self._turn_number}",
recognition_outcome=recognition_outcome,
transitions=(),
)
epistemic_graph = EpistemicGraph(
nodes=(node,),
recognizer_id=self._recognizer.teaching_set_id,
)
elif recognition_outcome.refusal_reason is not None:
recognition_refusal_str = repr(
recognition_outcome.refusal_reason.as_dict()
)
# 1. LISTEN — pre-turn field state (existing code, unchanged)
...
```
### `recognition_grounded_graph` flag
Add to `RuntimeConfig`:
```python
# ADR-0144 — recognition-grounded articulation graph. When True and a
# DerivedRecognizer is attached to the pipeline, the articulation graph
# is derived from the admitted EpistemicNode via the connector rather
# than from intent classification. Default False preserves byte-identity
# for every existing surface and trace_hash.
recognition_grounded_graph: bool = False
```
When `recognition_grounded_graph=True` and `epistemic_graph is not None`,
replace the intent-derived `graph` with one constructed from the connector:
```python
if self.runtime.config.recognition_grounded_graph and epistemic_graph is not None:
derived_node = epistemic_graph.nodes[0]
derived_graph_node = epistemic_node_to_graph_node(
derived_node, source_intent=intent.tag
)
graph = PropositionGraph().add_node(derived_graph_node)
target = plan_articulation(graph)
```
When `recognition_grounded_graph=False` (default), the intent-derived
`graph` is used unchanged — byte-identical to pre-ADR-0144.
### `CognitiveTurnResult` addition
```python
# --- recognition / epistemic carrier (ADR-0144) ---
# ``epistemic_graph`` is None when no DerivedRecognizer is attached,
# when recognition refused, or on the first turn before any recognizer
# is configured. Non-None only when recognition admitted.
# NOT folded into trace_hash in Phase 1 (observability only);
# trace_hash participation is gated on session-persistent provenance
# (post-ADR-0144 scope).
epistemic_graph: EpistemicGraph | None = None
```
---
## Implementation debt: `_ratify_intent` PASSTHROUGH collapse
The `_ratify_intent` method collapses three distinct cold-start conditions
into one indistinguishable `PASSTHROUGH` outcome, making it impossible to
diagnose which precondition failed (ADR-0142 §Implementation debts, debt 1).
Fix as part of this ADR since the wiring change touches `_ratify_intent`'s
callers:
Extend `RatificationOutcome` (in `generate/intent_ratifier.py`) with three
distinct passthrough values:
```python
class RatificationOutcome(Enum):
RATIFIED = "ratified"
DEMOTED = "demoted"
PASSTHROUGH_NO_FIELD = "passthrough_no_field" # field_state is None
PASSTHROUGH_NO_VOCAB = "passthrough_no_vocab" # vocab is None
PASSTHROUGH_NO_VERSOR = "passthrough_no_versor" # prompt_versor is None
# Backward-compatible alias so existing callers checking
# outcome == PASSTHROUGH keep working during the transition.
PASSTHROUGH = "passthrough"
```
Update `_ratify_intent` to emit the specific value. Update
`compute_trace_hash` to continue treating all four PASSTHROUGH variants
identically (fold the `.value` string; callers that checked
`== "passthrough"` now check `in _PASSTHROUGH_OUTCOMES`).
---
## Acceptance test
### Phase 1 — admitted recognition produces a carrier
Given a `DerivedRecognizer` derived from Phase 1 or Phase 2 teaching
examples and an admissible input:
1. `CognitiveTurnPipeline(runtime, recognizer=recognizer).run(text)` returns
a `CognitiveTurnResult` where `epistemic_graph` is not `None`.
2. `epistemic_graph.nodes` has exactly one node.
3. `node.epistemic_state == "evidenced"`.
4. `node.recognition_outcome.proposition` is the same `FeatureBundle`
returned by `recognize(recognizer, tokens)` directly — field-for-field
equal.
5. `node.recognition_outcome.provenance.teaching_set_id ==
recognizer.teaching_set_id`.
6. Two runs produce byte-identical `epistemic_graph.to_json()`.
7. All existing `core test --suite smoke -q` tests pass (no regressions).
### Phase 2 — refused recognition produces no carrier
Given the same recognizer and an inadmissible input:
1. `CognitiveTurnResult.epistemic_graph is None`.
2. The pipeline completes without raising.
3. `CognitiveTurnResult.trace_hash` is byte-identical across two runs.
4. All existing tests pass.
### Phase 3 — connector produces a valid articulation graph
Given an admitted `EpistemicNode` from a Phase 1/2 recognizer:
1. `epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)`
returns a `GraphNode` with non-empty `subject`, `predicate`, `obj`.
2. `PropositionGraph().add_node(derived_node)` passes `plan_articulation()`
without raising.
3. With `recognition_grounded_graph=True`, the pipeline produces a surface
derived from the feature bundle's agent/relation/count/unit fields.
4. With `recognition_grounded_graph=False` (default), output is
byte-identical to pre-ADR-0144 on the same input.
---
## File layout
```
recognition/
__init__.py (existing — add EpistemicGraph, EpistemicNode to __all__)
outcome.py (existing — unchanged)
anti_unifier.py (existing — unchanged)
carrier.py (NEW — EpistemicTransition, EpistemicNode, EpistemicGraph)
connector.py (NEW — epistemic_node_to_graph_node)
core/config.py (add recognition_grounded_graph: bool = False)
core/cognition/
pipeline.py (add recognizer param; wire recognition step; add
epistemic_graph to CognitiveTurnResult construction)
result.py (add epistemic_graph: EpistemicGraph | None = None)
generate/
intent_ratifier.py (extend RatificationOutcome with three PASSTHROUGH variants)
tests/
test_epistemic_carrier.py (NEW — acceptance test phases 13)
```
---
## What this ADR does NOT commit
- **Verifier implementation.** The `EpistemicNode.with_transition()` API
exists so the verifier can append transitions; the verifier itself is
out of scope.
- **Vault cross-reference.** VERIFIED → DECODED transition requires vault
replay-equality check. Deferred.
- **Session-persistent graph.** Per-turn carrier is the gate. Persistent
session graph (propositions survive across turns) requires a session home.
- **Storage layer for DerivedRecognizer.** Where recognizers live (pack /
vault / substrate) is deferred from ADR-0143.
- **Trace hash participation for `epistemic_graph`.** `EpistemicGraph` is
not folded into `trace_hash` in Phase 1. That gate opens when
session-persistent provenance lands.
- **Connector generalization.** The v1 connector covers `has`-relation
feature bundles only. New proposition types extend the mapping table.
- **Grounding-source dispatcher provenance gaps.** Six gaps identified in
ADR-0142 §Implementation debts, debt 2. Require a session carrier before
they can be fixed. Post-ADR-0144.
- **Teaching pipeline `MetricSet` dataclass.** ADR-0142 §Implementation
debts, debt 3. Not blocked by PropositionGraph; tracked separately.
---
## Consequences
- `CognitiveTurnPipeline` grows a `recognizer` constructor parameter.
Default `None` — all existing callers unaffected.
- `CognitiveTurnResult` grows `epistemic_graph: EpistemicGraph | None`.
Default `None` — all existing serialization unaffected.
- `RuntimeConfig` grows `recognition_grounded_graph: bool = False`.
Default preserves byte-identity.
- `RatificationOutcome` grows three specific PASSTHROUGH values. Existing
callers checking `== "passthrough"` must migrate to an `in` check;
the backward-compatible `PASSTHROUGH = "passthrough"` alias covers the
transition window.
- Recognition is now a first-class step in the cognitive turn. Every
UNDETERMINED / CONTRADICTED / AMBIGUOUS outcome is auditable —
it carries a typed `RefusalReason` — rather than being silently absent.
Refusal is teaching signal, not silence.
- Integration into the live generation path is explicit and opt-in
(`recognition_grounded_graph=True`). Operators control when recognized
propositions replace intent-derived articulation graphs.

View file

@ -0,0 +1,333 @@
# Scope: PropositionGraph — Epistemic Carrier for ADR-0144
**Status:** Draft / scope-only — prerequisite for ADR-0144
**Date:** 2026-05-24
**Author:** CORE agents
**Anchor:** [thesis-decoding-not-generating](../../../.claude/projects/-Users-kaizenpro-Projects-core/memory/thesis-decoding-not-generating.md) (memory)
**Related:** ADR-0142 (epistemic state taxonomy), ADR-0143 (recognition spike)
**Gated by:** Recognition spike complete (PRs #225, #224, #226 merged)
---
## Why this document exists
ADR-0142 and ADR-0143 each defer their integration work to ADR-0144, naming
the PropositionGraph as the missing carrier. The recognition spike is now
complete — `recognition/outcome.py` defines the stable output contract,
`recognition/anti_unifier.py` implements Phases 1 and 2, and 8/8 tests
pass. The carrier question can no longer be deferred.
But "PropositionGraph" is currently ambiguous: the name already exists in
the codebase with a different meaning.
---
## What "PropositionGraph" means today vs. what ADR-0144 needs
**Current `generate/graph_planner.py::PropositionGraph`** is a
*generation-side articulation planner*. It holds:
```python
GraphNode:
node_id: str
subject: str # raw text fragment from intent classification
predicate: str # intent-derived predicate label
obj: str # "<pending>" until grounded from vault recall
source_intent: IntentTag
```
Its purpose is to determine *what to say and in what order*. It drives
`plan_articulation()``ArticulationTarget``realize_semantic()`.
**What ADR-0144 needs** is a carrier that holds propositions *as they are
known* — not how they will be voiced. That carrier must:
1. Accept a `RecognitionOutcome` (from `recognition/anti_unifier.py`) as
the epistemic content of a node.
2. Carry the `EpistemicState` that applies to this proposition at each
pipeline stage.
3. Record provenance: which evidence spans, which recognizer, which
verification step moved the state.
4. Allow downstream stages (verifier, vault) to transition the state and
append provenance without mutating the original record.
5. Be serializable for replay (determinism guarantee from ADR-0143).
These two things — articulation planner and epistemic carrier — solve
different problems. Whether they should be the same object is the first
design question this scope must answer.
---
## The load-bearing question
> **What structure should carry a recognized proposition from recognition
> through the engine's subsystems (recognition → verifier → vault →
> articulation) such that:**
>
> 1. The `RecognitionOutcome` (including all feature evidence spans) is
> preserved and accessible at every stage,
> 2. Epistemic state transitions are themselves deterministic, typed, and
> carry provenance (what caused the transition),
> 3. The carrier is serializable to/from JSON for replay,
> 4. Cold-start turns (where recognition produces UNDETERMINED) leave the
> existing pipeline path unchanged, and
> 5. The articulation layer can still derive what to say, either from the
> epistemic carrier or from a parallel intent-derived graph?
---
## Three open questions
### Q1 — Carrier structure: one graph or two?
**Option A — Extend `GraphNode`**
Add `recognition_outcome: RecognitionOutcome | None` and
`epistemic_state: EpistemicState` to the existing `GraphNode`. The
generation-side graph absorbs epistemic tracking.
Pros: minimal new API surface; `CognitiveTurnResult.proposition_graph`
already exists.
Cons: mixes articulation planning (string fields: subject, predicate, obj)
with epistemic tracking (feature bundle, evidence spans, state history)
into one class. The two concerns have different mutation rules — articulation
fields are set once at planning time; epistemic state transitions on every
subsystem boundary.
**Option B — Separate `EpistemicGraph`**
A new `EpistemicNode` / `EpistemicGraph` type lives in `recognition/` or a
new `cognition/` carrier module. It carries the recognition outcome and
epistemic provenance chain. At articulation time, a connector maps
`EpistemicNode``GraphNode` (deriving subject/predicate/obj from the
feature bundle).
Pros: clean separation of concerns; neither class pollutes the other's
invariants; the generation-side graph keeps working as-is.
Cons: a connector must be written and tested; two graphs travel together
through the pipeline.
**Option C — Replace `GraphNode` string fields**
`GraphNode` string fields (`subject`, `predicate`, `obj`) are replaced
with feature-bundle representations. The proposition IS a feature bundle,
not a text fragment.
Pros: most thesis-aligned long-term — the engine stops carrying text
fragments as stand-ins for decoded propositions.
Cons: largest change surface; breaks every existing caller of `GraphNode`;
requires all existing tests to be updated.
**Recommendation candidate:** Option B. Option A mingles invariants that
have different mutation rules. Option C is the right long-term direction but
requires retiring the entire generation-side graph contract in one move —
too large a blast radius before the PropositionGraph has even been defined.
Option B lets the epistemic carrier evolve independently while the existing
articulation path continues to pass its tests. The connector is the one new
seam.
*The scope does not commit to Option B — the ADR decides.*
### Q2 — Session lifetime: per-turn or persistent?
The existing `PropositionGraph` is rebuilt every turn from intent
classification. The `_last_node_id` in `CognitiveTurnPipeline` threads a
single pointer across turns (for correction chaining), but not the full
graph.
For an epistemic carrier, the question is harder:
- **Per-turn:** Each turn derives its own epistemic carrier from the
recognized proposition. State from prior turns is not carried forward
in the graph. Simple; matches current session semantics.
- **Session-persistent:** The epistemic graph grows across turns.
Propositions from earlier turns remain accessible and can be
VERIFIED or DECODED in later turns (e.g., the engine verifies a
proposition from turn 3 after receiving correction in turn 5).
Required by ADR-0142's "transition history" provenance requirement in
the full-provenance case.
Per-turn is sufficient for the ADR-0144 gate. Session-persistent is
required by ADR-0142's full provenance enforcement but is gated on the
graph having a session home (vault? session context?).
**Recommendation candidate:** Per-turn for ADR-0144; session-persistent
is post-ADR-0144 scope.
*The scope does not commit — the ADR decides.*
### Q3 — Cold-start behavior: what happens when recognition refuses?
When the recognizer returns `state=UNDETERMINED`, there is no feature
bundle to put in an epistemic node. The pipeline must still:
- Route the turn through the existing intent-classification path
- Emit a `CognitiveTurnResult` with the refusal reason accessible
- Not drop the refusal — it is teaching signal (ADR-0143 §Consequences)
Two options:
- **Empty-carrier:** The epistemic carrier exists but its node has
`proposition=None` and `state=UNDETERMINED`. The existing pipeline
path handles surface generation; the carrier is observability only.
- **No-carrier:** If recognition refuses, the epistemic carrier is not
created and `CognitiveTurnResult.epistemic_graph` is `None`. The
refusal reason is attached to `CognitiveTurnResult.refusal_reason`
directly (which already exists).
The no-carrier option requires no new `CognitiveTurnResult` field and
is backward compatible. The empty-carrier option keeps the graph
always-present, which simplifies callers.
*The scope does not commit — the ADR decides.*
---
## Subsystem wiring (what ADR-0144 must specify)
Regardless of which option answers Q1Q3, ADR-0144 must wire the following
path end-to-end and verify it with a determinism test:
```
text
└─ tokenize()
└─ recognize(recognizer, tokens) # recognition/anti_unifier.py
└─ RecognitionOutcome
└─ EpistemicNode(state=EVIDENCED, bundle=..., provenance=...)
└─ [verifier] → state transition: EVIDENCED → VERIFIED
└─ [vault cross-ref] → state transition: VERIFIED → DECODED (when replay-equal)
└─ [connector] → GraphNode(subject, predicate, obj derived from bundle)
└─ plan_articulation() → ArticulationTarget
└─ realize_semantic() → surface
```
Three integration points the ADR must specify:
1. **Recognition → carrier:** How `RecognitionOutcome` is wrapped into
an epistemic node. Which field carries the `DerivedRecognizer` used
(for replay)?
2. **Verifier → carrier:** How the verifier transitions state and appends
provenance. What triggers verification (all EVIDENCED propositions?
intent-filtered?)?
3. **Carrier → articulation:** How the connector derives `subject`,
`predicate`, `obj` from a `FeatureBundle`. Feature bundle has
`agent`, `relation`, `count`, `unit` — the articulation planner
currently expects free-text strings. The mapping must be deterministic.
---
## Three implementation debts that become actionable here
From the ADR-0142 audit, three debts were deferred to ADR-0144:
1. **`_ratify_intent` PASSTHROUGH collapse** (`pipeline.py:390430`).
Three distinct cold-start conditions — `field_state is None`, `vocab
is None`, `prompt_versor is None` — all produce the same
`PASSTHROUGH` outcome with no way to distinguish them. Fix:
extend `RatificationOutcome` with three distinct enum values
(`PASSTHROUGH_NO_FIELD`, `PASSTHROUGH_NO_VOCAB`,
`PASSTHROUGH_NO_VERSOR`). Unblocked by ADR-0144 since the wiring
change will touch `_ratify_intent`'s callers.
2. **Chat runtime grounding-source dispatcher** (`runtime.py:8311012`).
Six provenance gaps: the dispatcher does not record which grounding
sources were attempted or why each fell through. Once the
PropositionGraph is the carrier, the dispatcher can attach a dispatch
trace to the graph node instead of losing it. Blocked until the node
exists.
3. **Teaching pipeline `watched-metrics` tuple** (`replay.py`). Should
be a named, versioned `MetricSet` dataclass. Survives future metric
additions without breaking trace byte-identity. Not directly
dependent on ADR-0144 but the ADR's determinism gate is the right
moment to fix it.
---
## What the smallest provable test looks like
**Phase 1 — recognition feeds the carrier (no verifier, no vault):**
Given a Phase 1 or Phase 2 `DerivedRecognizer` and an admissible input:
1. `recognize(recognizer, tokens)` returns `RecognitionOutcome(state=EVIDENCED, ...)`
2. The carrier wraps it as an `EpistemicNode` with `state=EVIDENCED`
3. The connector derives a `GraphNode` from the feature bundle
4. `plan_articulation(graph_with_derived_node)` returns a valid `ArticulationTarget`
5. `CognitiveTurnResult` carries the epistemic node (or graph) with the
original `RecognitionProvenance` intact
6. Two runs produce byte-identical `CognitiveTurnResult` records
**Phase 2 — refused input does not break the pipeline:**
Given an inadmissible input:
1. `recognize(recognizer, tokens)` returns `RecognitionOutcome(state=UNDETERMINED, ...)`
2. The pipeline routes through the existing intent-classification path
3. `CognitiveTurnResult.refusal_reason` carries the typed `ShapeRefusal`
4. `trace_hash` is byte-identical across two runs
---
## What this scope does NOT commit
- **Option selection for Q1Q3.** The ADR decides.
- **Storage layer for derived recognizers.** Deferred from ADR-0143 —
where recognizers live (pack / vault / substrate) is still open.
- **Full session-persistent provenance.** Per-turn carrier is the
ADR-0144 gate; session persistence is post-ADR-0144.
- **Verifier implementation.** ADR-0144 wires the integration point;
it does not implement the verifier.
- **Lens-conditional recognition.** How anchor lenses interact with
derived recognizers is deferred (named in ADR-0143 §What this ADR
does NOT commit).
- **`EpistemicNode` serialization format.** Defined by the ADR, not
this scope.
---
## Risks
- **Connector complexity.** Mapping a `FeatureBundle` to `GraphNode`
string fields (`subject`, `predicate`, `obj`) is straightforward for
Phase 1/2 examples but may not generalize cleanly to all future
proposition types. The ADR must either commit to a general mapping
rule or scope the first connector narrowly to the `has`-relation
feature bundles that exist today.
- **Trace hash breakage.** Every change to the fields folded into
`compute_trace_hash()` breaks byte-identity for all prior turns. The
ADR must specify which new fields (if any) are folded in, and whether
they are gated on non-emptiness (as `refusal_reason` is) to preserve
pre-ADR-0144 hashes.
- **`_ratify_intent` PASSTHROUGH** fires on every cold-start turn. If
ADR-0144 wires recognition before intent ratification, the cold-start
path must handle the case where the recognizer itself is not yet
derived — i.e., there is no `DerivedRecognizer` for this proposition
type yet. The engine must refuse cleanly, not fail.
- **`main` is Codex's checked-out branch.** Branch deletion via
`--delete-branch` on any PR may fail. Use `gh pr merge --squash`
without `--delete-branch`.
---
## Summary
The load-bearing question for ADR-0144 is what structure carries a
recognized proposition through the engine — from `RecognitionOutcome`
through verifier and vault to articulation — while preserving all evidence
spans and epistemic state provenance.
Three design questions are open:
1. One graph (extend `GraphNode`) or two (separate `EpistemicGraph`)?
2. Per-turn carrier or session-persistent?
3. Empty-carrier or no-carrier on recognition refusal?
The scope recommends two-graph and per-turn as the lower-blast-radius
options for the first integration gate, but the ADR decides.
Minimum deliverable for ADR-0144 acceptance: one recognized proposition
travels from `recognize()` through the carrier to a `CognitiveTurnResult`
with the original `RecognitionProvenance` intact, verified byte-identical
across two runs.

View file

@ -44,7 +44,18 @@ from generate.intent import DialogueIntent, IntentTag
class RatificationOutcome(Enum):
RATIFIED = "ratified"
DEMOTED = "demoted"
# Generic PASSTHROUGH — emitted by ratify_intent() when no vocab-grounded
# anchor exists or when the seed is already UNKNOWN. Preserved for callers
# that use RatificationOutcome.PASSTHROUGH directly (e.g. existing tests).
PASSTHROUGH = "passthrough"
# Specific PASSTHROUGH sub-values — emitted by _ratify_intent() in
# CognitiveTurnPipeline to distinguish the three cold-start conditions
# (ADR-0144 / ADR-0142 §Implementation debts, debt 1). All four PASSTHROUGH
# variants are normalised to "passthrough" before being folded into
# trace_hash so pre-ADR-0144 hashes remain byte-identical.
PASSTHROUGH_NO_FIELD = "passthrough_no_field"
PASSTHROUGH_NO_VOCAB = "passthrough_no_vocab"
PASSTHROUGH_NO_VERSOR = "passthrough_no_versor"
@dataclass(frozen=True, slots=True)

View file

@ -1 +1,11 @@
"""Teaching-derived structural recognition — ADR-0143."""
"""Teaching-derived structural recognition — ADR-0143 / ADR-0144."""
from recognition.carrier import EpistemicGraph, EpistemicNode, EpistemicTransition
from recognition.connector import epistemic_node_to_graph_node
__all__ = [
"EpistemicGraph",
"EpistemicNode",
"EpistemicTransition",
"epistemic_node_to_graph_node",
]

128
recognition/carrier.py Normal file
View file

@ -0,0 +1,128 @@
"""Epistemic carrier for recognized propositions — ADR-0144.
EpistemicNode wraps a RecognitionOutcome with an append-only provenance
chain of state transitions. EpistemicGraph holds one or more nodes for
a single turn plus the recognizer identity used to produce them.
Both types are frozen and serialisable to/from JSON so the carrier
participates in the determinism guarantee inherited from ADR-0143.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from recognition.outcome import RecognitionOutcome
@dataclass(frozen=True, slots=True)
class EpistemicTransition:
"""A single epistemic state transition with its provenance.
``from_state`` and ``to_state`` are values from the ADR-0142 taxonomy.
``source`` identifies the subsystem that caused the transition (e.g.
``"verifier"``, ``"vault"``). ``reason`` is human-readable audit text
and is not load-bearing for replay.
"""
from_state: str
to_state: str
source: str
reason: str
def as_dict(self) -> dict[str, Any]:
return {
"from_state": self.from_state,
"to_state": self.to_state,
"reason": self.reason,
"source": self.source,
}
@dataclass(frozen=True, slots=True)
class EpistemicNode:
"""One recognized proposition with full provenance chain.
``node_id`` is deterministic: the teaching_set_id of the DerivedRecognizer
used, suffixed with ``:<turn_index>`` byte-identical across runs on the
same recognizer and input.
``recognition_outcome`` is the frozen ADR-0143 output carrying the
FeatureBundle (or refusal reason) and RecognitionProvenance.
``transitions`` accumulates provenance as subsystems transition the state.
Empty on construction the recognizer's emission state is authoritative
until a subsystem appends a transition.
"""
node_id: str
recognition_outcome: RecognitionOutcome
transitions: tuple[EpistemicTransition, ...] = ()
@property
def epistemic_state(self) -> str:
"""Current state: last transition's to_state if any, else outcome.state."""
if self.transitions:
return self.transitions[-1].to_state
return self.recognition_outcome.state
def with_transition(self, transition: EpistemicTransition) -> "EpistemicNode":
"""Return a new node with the transition appended (immutable update)."""
return EpistemicNode(
node_id=self.node_id,
recognition_outcome=self.recognition_outcome,
transitions=(*self.transitions, transition),
)
def as_dict(self) -> dict[str, Any]:
return {
"epistemic_state": self.epistemic_state,
"node_id": self.node_id,
"recognition_outcome": self.recognition_outcome.as_dict(),
"transitions": [t.as_dict() for t in self.transitions],
}
@dataclass(frozen=True, slots=True)
class EpistemicGraph:
"""Per-turn epistemic carrier for recognized propositions.
``nodes`` is a tuple of EpistemicNodes in recognition order.
ADR-0144 Phase 1 emits exactly one node per admitted turn.
``recognizer_id`` is the ``teaching_set_id`` of the DerivedRecognizer
used byte-identical across runs on the same recognizer and input,
carrying replay identity.
``to_json()`` must be byte-identical across runs on the same input and
recognizer (determinism guarantee from ADR-0143).
"""
nodes: tuple[EpistemicNode, ...]
recognizer_id: str
def get(self, node_id: str) -> EpistemicNode | None:
for node in self.nodes:
if node.node_id == node_id:
return node
return None
def as_dict(self) -> dict[str, Any]:
return {
"nodes": [n.as_dict() for n in self.nodes],
"recognizer_id": self.recognizer_id,
}
def to_json(self) -> str:
return json.dumps(
self.as_dict(), ensure_ascii=False, separators=(",", ":"), sort_keys=True
)
__all__ = [
"EpistemicGraph",
"EpistemicNode",
"EpistemicTransition",
]

66
recognition/connector.py Normal file
View file

@ -0,0 +1,66 @@
"""Connector: EpistemicNode → GraphNode — ADR-0144.
Maps an admitted EpistemicNode's FeatureBundle to a generation-side
GraphNode so the recognition path can feed the articulation planner.
The v1 mapping covers has-relation feature bundles (agent, relation,
count, unit). New proposition types extend the mapping here; unknown
feature layouts raise ValueError so gaps surface explicitly rather than
silently defaulting.
"""
from __future__ import annotations
from generate.graph_planner import GraphNode
from generate.intent import IntentTag
from recognition.carrier import EpistemicNode
from recognition.outcome import EVIDENCED
def epistemic_node_to_graph_node(
node: EpistemicNode,
*,
source_intent: IntentTag,
node_id: str | None = None,
) -> GraphNode:
"""Derive a generation-side GraphNode from an admitted EpistemicNode.
Raises ``ValueError`` if ``node.recognition_outcome.state != EVIDENCED``.
Feature-bundle GraphNode mapping (v1, has-relation propositions):
subject bundle["agent"].value
predicate bundle["relation"].value
obj "{count.value} {unit.value}"
"""
outcome = node.recognition_outcome
if outcome.state != EVIDENCED:
raise ValueError(
f"Cannot derive GraphNode from non-EVIDENCED EpistemicNode: "
f"state={outcome.state!r}"
)
bundle = outcome.proposition
assert bundle is not None # invariant: EVIDENCED → proposition not None
agent = bundle.get("agent")
relation = bundle.get("relation")
count = bundle.get("count")
unit = bundle.get("unit")
subject = str(agent.value) if agent is not None else "<unknown-agent>"
predicate = str(relation.value) if relation is not None else "has"
obj = (
f"{count.value} {unit.value}"
if count is not None and unit is not None
else "<pending>"
)
return GraphNode(
node_id=node_id or node.node_id,
subject=subject,
predicate=predicate,
obj=obj,
source_intent=source_intent,
)
__all__ = ["epistemic_node_to_graph_node"]

View file

@ -0,0 +1,335 @@
"""Acceptance tests for ADR-0144 — PropositionGraph epistemic carrier.
Three phases:
Phase 1 admitted recognition produces a carrier with full provenance.
Phase 2 refused recognition produces no carrier; pipeline is unaffected.
Phase 3 connector derives a valid articulation GraphNode from the carrier.
"""
from __future__ import annotations
import json
import pytest
from generate.graph_planner import PropositionGraph, plan_articulation
from generate.intent import IntentTag
from recognition.anti_unifier import DerivedRecognizer, derive_recognizer, recognize
from recognition.carrier import EpistemicGraph, EpistemicNode, EpistemicTransition
from recognition.connector import epistemic_node_to_graph_node
from recognition.outcome import (
AMBIGUOUS,
CONTRADICTED,
EVIDENCED,
UNDETERMINED,
BoundFeature,
EvidenceSpan,
FeatureBundle,
NegativeEvidence,
)
# ---------------------------------------------------------------------------
# Shared fixture — Phase 1 teaching examples and recognizer
# ---------------------------------------------------------------------------
def _make_phase1_examples() -> list[tuple[tuple[str, ...], FeatureBundle]]:
def span(tokens: tuple[str, ...], s: int, e: int) -> EvidenceSpan:
return EvidenceSpan(start=s, end=e, text=" ".join(tokens[s:e]))
rows = [
("John", "has", "5", "apples"),
("Mary", "has", "3", "books"),
("A", "school", "has", "100", "students"),
("The", "library", "has", "12", "chairs"),
]
examples = []
for tokens in rows:
t = tokens
# agent is the last token(s) before "has"; count and unit follow it
has_idx = t.index("has")
agent_start = 1 if t[0].lower() in {"a", "the"} else 0
bundle = FeatureBundle.from_mapping({
"agent": (
" ".join(t[agent_start:has_idx]),
span(t, agent_start, has_idx),
),
"count": (int(t[has_idx + 1]), span(t, has_idx + 1, has_idx + 2)),
"intentionality": (
"possession",
NegativeEvidence(0, len(t), "lexical content of 'has'"),
),
"modality": (
"actual",
NegativeEvidence(0, len(t), "no modal counter-marker present"),
),
"polarity": (
"+",
NegativeEvidence(0, len(t), "no negator present"),
),
"relation": ("has", span(t, has_idx, has_idx + 1)),
"tense": ("present", span(t, has_idx, has_idx + 1)),
"unit": (t[has_idx + 2].rstrip("s"), span(t, has_idx + 2, has_idx + 3)),
})
examples.append((t, bundle))
return examples
@pytest.fixture(scope="module")
def phase1_recognizer() -> DerivedRecognizer:
return derive_recognizer(_make_phase1_examples())
# ---------------------------------------------------------------------------
# Phase 1 — admitted recognition produces a carrier
# ---------------------------------------------------------------------------
class TestPhase1AdmittedCarrier:
def test_epistemic_graph_is_not_none_on_admit(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
assert outcome.admitted
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
graph = EpistemicGraph(
nodes=(node,),
recognizer_id=phase1_recognizer.teaching_set_id,
)
assert graph is not None
assert len(graph.nodes) == 1
def test_node_epistemic_state_is_evidenced(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
assert node.epistemic_state == EVIDENCED
def test_feature_bundle_preserved_in_node(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
bundle = node.recognition_outcome.proposition
assert bundle is not None
assert bundle.get("count") is not None
assert bundle.get("count").value == 24
assert bundle.get("agent") is not None
def test_recognizer_id_matches_teaching_set_id(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
graph = EpistemicGraph(nodes=(node,), recognizer_id=phase1_recognizer.teaching_set_id)
assert graph.recognizer_id == phase1_recognizer.teaching_set_id
assert graph.recognizer_id == outcome.provenance.teaching_set_id
def test_to_json_is_byte_identical_across_runs(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
def make_graph() -> EpistemicGraph:
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
return EpistemicGraph(
nodes=(node,), recognizer_id=phase1_recognizer.teaching_set_id
)
g1 = make_graph()
g2 = make_graph()
assert g1 == g2
assert g1.to_json() == g2.to_json()
def test_no_transitions_on_construction(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
assert node.transitions == ()
def test_with_transition_appends_and_updates_state(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
transition = EpistemicTransition(
from_state=EVIDENCED,
to_state="verified",
source="verifier",
reason="pack cross-reference matched",
)
updated = node.with_transition(transition)
assert updated.epistemic_state == "verified"
assert len(updated.transitions) == 1
assert updated.transitions[0] is transition
# Original node is unchanged (immutable)
assert node.epistemic_state == EVIDENCED
assert node.transitions == ()
# ---------------------------------------------------------------------------
# Phase 2 — refused recognition produces no carrier; pipeline unaffected
# ---------------------------------------------------------------------------
class TestPhase2RefusedNoCarrier:
def test_shape_refusal_yields_none_carrier(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("John", "gave", "5", "apples", "to", "Mary")
outcome = recognize(phase1_recognizer, tokens)
assert outcome.state == UNDETERMINED
assert not outcome.admitted
# No carrier created on refusal
epistemic_graph = None
if outcome.admitted:
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
epistemic_graph = EpistemicGraph(
nodes=(node,), recognizer_id=phase1_recognizer.teaching_set_id
)
assert epistemic_graph is None
def test_refusal_outcome_carries_typed_reason(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("John", "gave", "5", "apples", "to", "Mary")
outcome = recognize(phase1_recognizer, tokens)
assert outcome.refusal_reason is not None
d = outcome.refusal_reason.as_dict()
assert d["type"] == "shape"
def test_graph_get_returns_none_for_missing_id(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id="n0", recognition_outcome=outcome
)
graph = EpistemicGraph(nodes=(node,), recognizer_id="x")
assert graph.get("n0") is node
assert graph.get("missing") is None
# ---------------------------------------------------------------------------
# Phase 3 — connector derives a valid articulation GraphNode
# ---------------------------------------------------------------------------
class TestPhase3Connector:
def test_connector_produces_graph_node(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
gn = epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)
assert gn.subject != ""
assert gn.predicate != ""
assert gn.obj != ""
assert gn.source_intent is IntentTag.RECALL
def test_connector_agent_and_relation_lifted(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
gn = epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)
assert gn.subject == "baker"
assert gn.predicate == "has"
assert "24" in gn.obj
def test_connector_raises_on_non_evidenced_node(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("John", "gave", "5", "apples", "to", "Mary")
outcome = recognize(phase1_recognizer, tokens)
assert not outcome.admitted
node = EpistemicNode(
node_id="n0", recognition_outcome=outcome
)
with pytest.raises(ValueError, match="non-EVIDENCED"):
epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)
def test_derived_graph_node_passes_plan_articulation(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
gn = epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)
graph = PropositionGraph().add_node(gn)
target = plan_articulation(graph)
assert len(target.steps) == 1
assert target.steps[0].subject == "baker"
def test_node_id_override(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(node_id="original", recognition_outcome=outcome)
gn = epistemic_node_to_graph_node(
node, source_intent=IntentTag.RECALL, node_id="override"
)
assert gn.node_id == "override"
def test_connector_is_deterministic(
self, phase1_recognizer: DerivedRecognizer
) -> None:
tokens = ("A", "baker", "has", "24", "loaves")
def make_gn():
outcome = recognize(phase1_recognizer, tokens)
node = EpistemicNode(
node_id=f"{phase1_recognizer.teaching_set_id}:0",
recognition_outcome=outcome,
)
return epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)
gn1 = make_gn()
gn2 = make_gn()
assert gn1 == gn2