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:
parent
23ce6f9a06
commit
87b0eda345
10 changed files with 1476 additions and 13 deletions
|
|
@ -29,7 +29,15 @@ from generate.intent_ratifier import (
|
||||||
RatifiedIntent,
|
RatifiedIntent,
|
||||||
ratify_intent,
|
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.realizer import realize_semantic
|
||||||
from generate.intent import IntentTag
|
from generate.intent import IntentTag
|
||||||
from generate.operators import (
|
from generate.operators import (
|
||||||
|
|
@ -88,6 +96,16 @@ _SUBJECT_STOPWORDS: frozenset[str] = frozenset({
|
||||||
# promotion removes it explicitly.
|
# promotion removes it explicitly.
|
||||||
_MAX_SPECULATIVE_SUBJECTS = 64
|
_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:
|
class CognitiveTurnPipeline:
|
||||||
"""Thin pipeline wrapper over ChatRuntime.
|
"""Thin pipeline wrapper over ChatRuntime.
|
||||||
|
|
@ -96,10 +114,16 @@ class CognitiveTurnPipeline:
|
||||||
a place to plug in. No new intelligence is added here.
|
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.runtime = runtime
|
||||||
self._last_node_id: str | None = None
|
self._last_node_id: str | None = None
|
||||||
self.teaching_store = teaching_store or TeachingStore()
|
self.teaching_store = teaching_store or TeachingStore()
|
||||||
|
self._recognizer = recognizer
|
||||||
self._prior_surface: str | None = None
|
self._prior_surface: str | None = None
|
||||||
self._turn_number: int = 0
|
self._turn_number: int = 0
|
||||||
# ADR-0021 §Articulation: subjects of prior SPECULATIVE teaching
|
# 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:
|
def run(self, text: str, max_tokens: int | None = None) -> CognitiveTurnResult:
|
||||||
"""Execute one full cognitive turn and return a complete result record."""
|
"""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
|
# 1. LISTEN — capture pre-turn field state
|
||||||
field_state_before: FieldState | None = self._capture_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)
|
graph = graph_from_intent(intent, prior_node_id=prior_node_id)
|
||||||
target = plan_articulation(graph)
|
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.
|
# 1c. REALIZE — semantic realization from graph + intent.
|
||||||
# Pre-fix (and default today) the realizer fires on the
|
# Pre-fix (and default today) the realizer fires on the
|
||||||
# ungrounded graph and emits ``<pending>`` / ``...`` surfaces
|
# ungrounded graph and emits ``<pending>`` / ``...`` surfaces
|
||||||
|
|
@ -243,8 +298,7 @@ class CognitiveTurnPipeline:
|
||||||
# 9. Reconstruct input-layer tokens from the turn log
|
# 9. Reconstruct input-layer tokens from the turn log
|
||||||
# (turn_log is appended inside chat(); last entry matches this turn)
|
# (turn_log is appended inside chat(); last entry matches this turn)
|
||||||
# When the unknown-domain gate fires, chat() returns a stub without
|
# When the unknown-domain gate fires, chat() returns a stub without
|
||||||
# appending to turn_log — fall back to the tokenizer.
|
# appending to turn_log — fall back to raw_tokens (set at step 0).
|
||||||
raw_tokens = tuple(self.runtime.tokenize(text))
|
|
||||||
if self.runtime.turn_log:
|
if self.runtime.turn_log:
|
||||||
last_turn = self.runtime.turn_log[-1]
|
last_turn = self.runtime.turn_log[-1]
|
||||||
filtered_tokens = last_turn.input_tokens
|
filtered_tokens = last_turn.input_tokens
|
||||||
|
|
@ -325,7 +379,17 @@ class CognitiveTurnPipeline:
|
||||||
admissibility_trace = response.admissibility_trace
|
admissibility_trace = response.admissibility_trace
|
||||||
region_was_unconstrained = response.region_was_unconstrained
|
region_was_unconstrained = response.region_was_unconstrained
|
||||||
admissibility_trace_hash = hash_admissibility_trace(admissibility_trace)
|
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
|
# ADR-0024 Phase 2 — refusal_reason flows from a future
|
||||||
# materialisation site on ChatResponse. Empty string on every
|
# materialisation site on ChatResponse. Empty string on every
|
||||||
# non-refused turn; folding into trace_hash is gated on
|
# non-refused turn; folding into trace_hash is gated on
|
||||||
|
|
@ -347,7 +411,7 @@ class CognitiveTurnPipeline:
|
||||||
teaching_epistemic_status=epistemic_status,
|
teaching_epistemic_status=epistemic_status,
|
||||||
operator_invocation=operator_invocation,
|
operator_invocation=operator_invocation,
|
||||||
admissibility_trace_hash=admissibility_trace_hash,
|
admissibility_trace_hash=admissibility_trace_hash,
|
||||||
ratification_outcome=ratification_outcome,
|
ratification_outcome=_trace_ratification_outcome,
|
||||||
region_was_unconstrained=region_was_unconstrained,
|
region_was_unconstrained=region_was_unconstrained,
|
||||||
refusal_reason=refusal_reason,
|
refusal_reason=refusal_reason,
|
||||||
)
|
)
|
||||||
|
|
@ -378,6 +442,7 @@ class CognitiveTurnPipeline:
|
||||||
ratification_outcome=ratification_outcome,
|
ratification_outcome=ratification_outcome,
|
||||||
region_was_unconstrained=region_was_unconstrained,
|
region_was_unconstrained=region_was_unconstrained,
|
||||||
refusal_reason=refusal_reason,
|
refusal_reason=refusal_reason,
|
||||||
|
epistemic_graph=epistemic_graph,
|
||||||
dropped_compound_clauses=dropped_compound_clauses,
|
dropped_compound_clauses=dropped_compound_clauses,
|
||||||
versor_condition=response.versor_condition,
|
versor_condition=response.versor_condition,
|
||||||
trace_hash=trace_hash,
|
trace_hash=trace_hash,
|
||||||
|
|
@ -390,14 +455,14 @@ class CognitiveTurnPipeline:
|
||||||
def _ratify_intent(self, intent, field_state):
|
def _ratify_intent(self, intent, field_state):
|
||||||
"""Field-ratify a seeded intent (ADR-0022 §TBD-1).
|
"""Field-ratify a seeded intent (ADR-0022 §TBD-1).
|
||||||
|
|
||||||
When no field state or no vocab is available (cold start),
|
Emits specific PASSTHROUGH sub-values (ADR-0144 / ADR-0142 debt 1)
|
||||||
ratification short-circuits to PASSTHROUGH and the seed
|
so the trace can distinguish which cold-start condition fired.
|
||||||
survives — the existing cold-start behavior is preserved.
|
All sub-values normalise to "passthrough" for trace_hash.
|
||||||
"""
|
"""
|
||||||
if field_state is None:
|
if field_state is None:
|
||||||
return RatifiedIntent(
|
return RatifiedIntent(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
outcome=RatificationOutcome.PASSTHROUGH,
|
outcome=RatificationOutcome.PASSTHROUGH_NO_FIELD,
|
||||||
score=0.0,
|
score=0.0,
|
||||||
threshold=0.0,
|
threshold=0.0,
|
||||||
seed_tag=intent.tag,
|
seed_tag=intent.tag,
|
||||||
|
|
@ -413,7 +478,7 @@ class CognitiveTurnPipeline:
|
||||||
if vocab is None:
|
if vocab is None:
|
||||||
return RatifiedIntent(
|
return RatifiedIntent(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
outcome=RatificationOutcome.PASSTHROUGH,
|
outcome=RatificationOutcome.PASSTHROUGH_NO_VOCAB,
|
||||||
score=0.0,
|
score=0.0,
|
||||||
threshold=0.0,
|
threshold=0.0,
|
||||||
seed_tag=intent.tag,
|
seed_tag=intent.tag,
|
||||||
|
|
@ -422,7 +487,7 @@ class CognitiveTurnPipeline:
|
||||||
if prompt_versor is None:
|
if prompt_versor is None:
|
||||||
return RatifiedIntent(
|
return RatifiedIntent(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
outcome=RatificationOutcome.PASSTHROUGH,
|
outcome=RatificationOutcome.PASSTHROUGH_NO_VERSOR,
|
||||||
score=0.0,
|
score=0.0,
|
||||||
threshold=0.0,
|
threshold=0.0,
|
||||||
seed_tag=intent.tag,
|
seed_tag=intent.tag,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from generate.graph_planner import ArticulationTarget, PropositionGraph
|
||||||
from generate.intent import DialogueIntent
|
from generate.intent import DialogueIntent
|
||||||
from generate.proposition import Proposition
|
from generate.proposition import Proposition
|
||||||
from core.physics.identity import IdentityScore
|
from core.physics.identity import IdentityScore
|
||||||
|
from recognition.carrier import EpistemicGraph
|
||||||
from teaching.correction import CorrectionCandidate
|
from teaching.correction import CorrectionCandidate
|
||||||
from teaching.review import ReviewedTeachingExample
|
from teaching.review import ReviewedTeachingExample
|
||||||
from teaching.store import PackMutationProposal
|
from teaching.store import PackMutationProposal
|
||||||
|
|
@ -100,6 +101,13 @@ class CognitiveTurnResult:
|
||||||
# in place when a future ADR wires the materialisation path.
|
# in place when a future ADR wires the materialisation path.
|
||||||
refusal_reason: str = ""
|
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) ---
|
# --- compound intent observability (ADR-0089 Phase C1) ---
|
||||||
# Finding 4 (audit 2026-05-20). ``classify_compound_intent`` returns
|
# Finding 4 (audit 2026-05-20). ``classify_compound_intent`` returns
|
||||||
# multiple parts for inputs like "What is X and how does it relate
|
# multiple parts for inputs like "What is X and how does it relate
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,13 @@ class RuntimeConfig:
|
||||||
# live workload.
|
# live workload.
|
||||||
unified_ingest: bool = False
|
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_IDENTITY_PACK: str = "default_general_v1"
|
||||||
DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1"
|
DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1"
|
||||||
|
|
|
||||||
500
docs/decisions/ADR-0144-proposition-graph-epistemic-carrier.md
Normal file
500
docs/decisions/ADR-0144-proposition-graph-epistemic-carrier.md
Normal 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 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`.** `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.
|
||||||
333
docs/decisions/proposition-graph-scope.md
Normal file
333
docs/decisions/proposition-graph-scope.md
Normal 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 Q1–Q3, 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:390–430`).
|
||||||
|
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:831–1012`).
|
||||||
|
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 Q1–Q3.** 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.
|
||||||
|
|
@ -44,7 +44,18 @@ from generate.intent import DialogueIntent, IntentTag
|
||||||
class RatificationOutcome(Enum):
|
class RatificationOutcome(Enum):
|
||||||
RATIFIED = "ratified"
|
RATIFIED = "ratified"
|
||||||
DEMOTED = "demoted"
|
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"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
|
||||||
|
|
@ -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
128
recognition/carrier.py
Normal 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
66
recognition/connector.py
Normal 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"]
|
||||||
335
tests/test_epistemic_carrier.py
Normal file
335
tests/test_epistemic_carrier.py
Normal 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
|
||||||
Loading…
Reference in a new issue