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.
19 KiB
ADR-0144: PropositionGraph — Epistemic Carrier and Recognition Integration Gate
Status: Accepted Date: 2026-05-24 Scope doc: proposition-graph-scope 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:
-
The name is taken.
generate/graph_planner.py::PropositionGraphis an articulation planner — it holdssubject: str,predicate: str,obj: strfor generation purposes. That is not the same as a carrier that holds aRecognitionOutcome, anEpistemicState, and a provenance chain across subsystem transitions. -
The pipeline has no recognition step.
CognitiveTurnPipeline.run()callsclassify_compound_intent()to derive intent and builds an articulation graph from intent labels. It never callsrecognize(). Therecognition/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-levelsubject,predicate,objfields for surface generation. Driven by intent classification. Unmodified. -
recognition/carrier.py::EpistemicGraph— epistemic carrier (new). HoldsEpistemicNoderecords carryingRecognitionOutcome+ transition provenance. Driven byrecognize(). Lives in therecognition/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
# 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
@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
@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 sameDerivedRecognizerand input token sequence.- Every
EpistemicNode.node_idwithin a graph is unique. EpistemicNode.transitionsis append-only. No transition is ever removed or replaced.
Connector: EpistemicNode → GraphNode
# 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
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():
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:
# 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:
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
# --- 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:
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:
CognitiveTurnPipeline(runtime, recognizer=recognizer).run(text)returns aCognitiveTurnResultwhereepistemic_graphis notNone.epistemic_graph.nodeshas exactly one node.node.epistemic_state == "evidenced".node.recognition_outcome.propositionis the sameFeatureBundlereturned byrecognize(recognizer, tokens)directly — field-for-field equal.node.recognition_outcome.provenance.teaching_set_id == recognizer.teaching_set_id.- Two runs produce byte-identical
epistemic_graph.to_json(). - All existing
core test --suite smoke -qtests pass (no regressions).
Phase 2 — refused recognition produces no carrier
Given the same recognizer and an inadmissible input:
CognitiveTurnResult.epistemic_graph is None.- The pipeline completes without raising.
CognitiveTurnResult.trace_hashis byte-identical across two runs.- All existing tests pass.
Phase 3 — connector produces a valid articulation graph
Given an admitted EpistemicNode from a Phase 1/2 recognizer:
epistemic_node_to_graph_node(node, source_intent=IntentTag.RECALL)returns aGraphNodewith non-emptysubject,predicate,obj.PropositionGraph().add_node(derived_node)passesplan_articulation()without raising.- With
recognition_grounded_graph=True, the pipeline produces a surface derived from the feature bundle's agent/relation/count/unit fields. - 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 1–3)
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.EpistemicGraphis not folded intotrace_hashin 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
MetricSetdataclass. ADR-0142 §Implementation debts, debt 3. Not blocked by PropositionGraph; tracked separately.
Consequences
CognitiveTurnPipelinegrows arecognizerconstructor parameter. DefaultNone— all existing callers unaffected.CognitiveTurnResultgrowsepistemic_graph: EpistemicGraph | None. DefaultNone— all existing serialization unaffected.RuntimeConfiggrowsrecognition_grounded_graph: bool = False. Default preserves byte-identity.RatificationOutcomegrows three specific PASSTHROUGH values. Existing callers checking== "passthrough"must migrate to anincheck; the backward-compatiblePASSTHROUGH = "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.