From 87b0eda34512a3f2070ee4a941fa65ddc039bbc5 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 24 May 2026 13:39:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(recognition):=20ADR-0144=20=E2=80=94=20Epi?= =?UTF-8?q?stemicGraph=20carrier=20+=20pipeline=20integration=20(#227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/cognition/pipeline.py | 89 +++- core/cognition/result.py | 8 + core/config.py | 7 + ...144-proposition-graph-epistemic-carrier.md | 500 ++++++++++++++++++ docs/decisions/proposition-graph-scope.md | 333 ++++++++++++ generate/intent_ratifier.py | 11 + recognition/__init__.py | 12 +- recognition/carrier.py | 128 +++++ recognition/connector.py | 66 +++ tests/test_epistemic_carrier.py | 335 ++++++++++++ 10 files changed, 1476 insertions(+), 13 deletions(-) create mode 100644 docs/decisions/ADR-0144-proposition-graph-epistemic-carrier.md create mode 100644 docs/decisions/proposition-graph-scope.md create mode 100644 recognition/carrier.py create mode 100644 recognition/connector.py create mode 100644 tests/test_epistemic_carrier.py diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index ac236546..7dde9c2f 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -29,7 +29,15 @@ from generate.intent_ratifier import ( RatifiedIntent, ratify_intent, ) -from generate.graph_planner import graph_from_intent, ground_graph, plan_articulation +from generate.graph_planner import ( + PropositionGraph, + graph_from_intent, + ground_graph, + plan_articulation, +) +from recognition.anti_unifier import DerivedRecognizer, recognize +from recognition.carrier import EpistemicGraph, EpistemicNode +from recognition.connector import epistemic_node_to_graph_node from generate.realizer import realize_semantic from generate.intent import IntentTag from generate.operators import ( @@ -88,6 +96,16 @@ _SUBJECT_STOPWORDS: frozenset[str] = frozenset({ # promotion removes it explicitly. _MAX_SPECULATIVE_SUBJECTS = 64 +# All PASSTHROUGH variants normalised to "passthrough" for trace_hash so +# pre-ADR-0144 hashes remain byte-identical after _ratify_intent gains +# specific sub-values (ADR-0144 / ADR-0142 §Implementation debts, debt 1). +_PASSTHROUGH_OUTCOMES: frozenset[str] = frozenset({ + "passthrough", + "passthrough_no_field", + "passthrough_no_vocab", + "passthrough_no_versor", +}) + class CognitiveTurnPipeline: """Thin pipeline wrapper over ChatRuntime. @@ -96,10 +114,16 @@ class CognitiveTurnPipeline: a place to plug in. No new intelligence is added here. """ - def __init__(self, runtime, teaching_store: TeachingStore | None = None) -> None: # runtime: ChatRuntime (no import cycle) + def __init__( + self, + runtime, + teaching_store: TeachingStore | None = None, + recognizer: DerivedRecognizer | None = None, + ) -> None: # runtime: ChatRuntime (no import cycle) self.runtime = runtime self._last_node_id: str | None = None self.teaching_store = teaching_store or TeachingStore() + self._recognizer = recognizer self._prior_surface: str | None = None self._turn_number: int = 0 # ADR-0021 §Articulation: subjects of prior SPECULATIVE teaching @@ -125,6 +149,25 @@ class CognitiveTurnPipeline: def run(self, text: str, max_tokens: int | None = None) -> CognitiveTurnResult: """Execute one full cognitive turn and return a complete result record.""" + # 0. TOKENIZE — once at the top; reused by recognition step and trace. + raw_tokens: tuple[str, ...] = tuple(self.runtime.tokenize(text)) + + # 0b. RECOGNIZE — if a DerivedRecognizer is attached (ADR-0144). + # Admitted → wrap in EpistemicGraph for observability and optional + # connector-grounded articulation. Refused or absent → None. + epistemic_graph: EpistemicGraph | None = None + if self._recognizer is not None: + _rec_outcome = recognize(self._recognizer, raw_tokens) + if _rec_outcome.admitted: + _ep_node = EpistemicNode( + node_id=f"{self._recognizer.teaching_set_id}:{self._turn_number}", + recognition_outcome=_rec_outcome, + ) + epistemic_graph = EpistemicGraph( + nodes=(_ep_node,), + recognizer_id=self._recognizer.teaching_set_id, + ) + # 1. LISTEN — capture pre-turn field state field_state_before: FieldState | None = self._capture_field_state() @@ -155,6 +198,18 @@ class CognitiveTurnPipeline: graph = graph_from_intent(intent, prior_node_id=prior_node_id) target = plan_articulation(graph) + # 1b.ii RECOGNITION-GROUNDED GRAPH (ADR-0144, opt-in). + # When recognition admitted and the operator has opted in, replace the + # intent-derived graph and articulation target with ones derived from + # the admitted EpistemicNode via the connector. Default False preserves + # byte-identity for every existing surface and trace_hash. + if self.runtime.config.recognition_grounded_graph and epistemic_graph is not None: + _derived_gn = epistemic_node_to_graph_node( + epistemic_graph.nodes[0], source_intent=intent.tag + ) + graph = PropositionGraph().add_node(_derived_gn) + target = plan_articulation(graph) + # 1c. REALIZE — semantic realization from graph + intent. # Pre-fix (and default today) the realizer fires on the # ungrounded graph and emits ```` / ``...`` surfaces @@ -243,8 +298,7 @@ class CognitiveTurnPipeline: # 9. Reconstruct input-layer tokens from the turn log # (turn_log is appended inside chat(); last entry matches this turn) # When the unknown-domain gate fires, chat() returns a stub without - # appending to turn_log — fall back to the tokenizer. - raw_tokens = tuple(self.runtime.tokenize(text)) + # appending to turn_log — fall back to raw_tokens (set at step 0). if self.runtime.turn_log: last_turn = self.runtime.turn_log[-1] filtered_tokens = last_turn.input_tokens @@ -325,7 +379,17 @@ class CognitiveTurnPipeline: admissibility_trace = response.admissibility_trace region_was_unconstrained = response.region_was_unconstrained admissibility_trace_hash = hash_admissibility_trace(admissibility_trace) - ratification_outcome = ratified.outcome.value + # Normalise all PASSTHROUGH sub-values to "passthrough" so the value + # stored in CognitiveTurnResult matches what goes into trace_hash + # (trace_hash_from_result invariant) and pre-ADR-0144 hashes remain + # byte-identical (ADR-0144 / ADR-0142 §Implementation debts, debt 1). + _ratification_outcome_raw = ratified.outcome.value + ratification_outcome = ( + "passthrough" + if _ratification_outcome_raw in _PASSTHROUGH_OUTCOMES + else _ratification_outcome_raw + ) + _trace_ratification_outcome = ratification_outcome # ADR-0024 Phase 2 — refusal_reason flows from a future # materialisation site on ChatResponse. Empty string on every # non-refused turn; folding into trace_hash is gated on @@ -347,7 +411,7 @@ class CognitiveTurnPipeline: teaching_epistemic_status=epistemic_status, operator_invocation=operator_invocation, admissibility_trace_hash=admissibility_trace_hash, - ratification_outcome=ratification_outcome, + ratification_outcome=_trace_ratification_outcome, region_was_unconstrained=region_was_unconstrained, refusal_reason=refusal_reason, ) @@ -378,6 +442,7 @@ class CognitiveTurnPipeline: ratification_outcome=ratification_outcome, region_was_unconstrained=region_was_unconstrained, refusal_reason=refusal_reason, + epistemic_graph=epistemic_graph, dropped_compound_clauses=dropped_compound_clauses, versor_condition=response.versor_condition, trace_hash=trace_hash, @@ -390,14 +455,14 @@ class CognitiveTurnPipeline: def _ratify_intent(self, intent, field_state): """Field-ratify a seeded intent (ADR-0022 §TBD-1). - When no field state or no vocab is available (cold start), - ratification short-circuits to PASSTHROUGH and the seed - survives — the existing cold-start behavior is preserved. + Emits specific PASSTHROUGH sub-values (ADR-0144 / ADR-0142 debt 1) + so the trace can distinguish which cold-start condition fired. + All sub-values normalise to "passthrough" for trace_hash. """ if field_state is None: return RatifiedIntent( intent=intent, - outcome=RatificationOutcome.PASSTHROUGH, + outcome=RatificationOutcome.PASSTHROUGH_NO_FIELD, score=0.0, threshold=0.0, seed_tag=intent.tag, @@ -413,7 +478,7 @@ class CognitiveTurnPipeline: if vocab is None: return RatifiedIntent( intent=intent, - outcome=RatificationOutcome.PASSTHROUGH, + outcome=RatificationOutcome.PASSTHROUGH_NO_VOCAB, score=0.0, threshold=0.0, seed_tag=intent.tag, @@ -422,7 +487,7 @@ class CognitiveTurnPipeline: if prompt_versor is None: return RatifiedIntent( intent=intent, - outcome=RatificationOutcome.PASSTHROUGH, + outcome=RatificationOutcome.PASSTHROUGH_NO_VERSOR, score=0.0, threshold=0.0, seed_tag=intent.tag, diff --git a/core/cognition/result.py b/core/cognition/result.py index 54973764..f2421bdb 100644 --- a/core/cognition/result.py +++ b/core/cognition/result.py @@ -17,6 +17,7 @@ from generate.graph_planner import ArticulationTarget, PropositionGraph from generate.intent import DialogueIntent from generate.proposition import Proposition from core.physics.identity import IdentityScore +from recognition.carrier import EpistemicGraph from teaching.correction import CorrectionCandidate from teaching.review import ReviewedTeachingExample from teaching.store import PackMutationProposal @@ -100,6 +101,13 @@ class CognitiveTurnResult: # in place when a future ADR wires the materialisation path. refusal_reason: str = "" + # --- recognition / epistemic carrier (ADR-0144) --- + # None when no DerivedRecognizer is attached, when recognition refused, + # or on the very first turn before any recognizer is configured. + # Non-None only when recognition admitted (state == EVIDENCED). + # NOT folded into trace_hash in Phase 1 (observability only). + epistemic_graph: EpistemicGraph | None = None + # --- compound intent observability (ADR-0089 Phase C1) --- # Finding 4 (audit 2026-05-20). ``classify_compound_intent`` returns # multiple parts for inputs like "What is X and how does it relate diff --git a/core/config.py b/core/config.py index bb47c229..693c630c 100644 --- a/core/config.py +++ b/core/config.py @@ -242,6 +242,13 @@ class RuntimeConfig: # live workload. unified_ingest: bool = False + # ADR-0144 — recognition-grounded articulation graph. When True and a + # DerivedRecognizer is attached to CognitiveTurnPipeline, the articulation + # graph is derived from the admitted EpistemicNode via the connector rather + # than from intent classification. Default False preserves byte-identity + # for every existing surface and trace_hash. + recognition_grounded_graph: bool = False + DEFAULT_IDENTITY_PACK: str = "default_general_v1" DEFAULT_ETHICS_PACK: str = "default_general_ethics_v1" diff --git a/docs/decisions/ADR-0144-proposition-graph-epistemic-carrier.md b/docs/decisions/ADR-0144-proposition-graph-epistemic-carrier.md new file mode 100644 index 00000000..7af9e909 --- /dev/null +++ b/docs/decisions/ADR-0144-proposition-graph-epistemic-carrier.md @@ -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 ``:`` (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 "" + 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 "" + ) + + 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. diff --git a/docs/decisions/proposition-graph-scope.md b/docs/decisions/proposition-graph-scope.md new file mode 100644 index 00000000..af44092f --- /dev/null +++ b/docs/decisions/proposition-graph-scope.md @@ -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 # "" 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. diff --git a/generate/intent_ratifier.py b/generate/intent_ratifier.py index a887bd10..6373ecc3 100644 --- a/generate/intent_ratifier.py +++ b/generate/intent_ratifier.py @@ -44,7 +44,18 @@ from generate.intent import DialogueIntent, IntentTag class RatificationOutcome(Enum): RATIFIED = "ratified" DEMOTED = "demoted" + # Generic PASSTHROUGH — emitted by ratify_intent() when no vocab-grounded + # anchor exists or when the seed is already UNKNOWN. Preserved for callers + # that use RatificationOutcome.PASSTHROUGH directly (e.g. existing tests). PASSTHROUGH = "passthrough" + # Specific PASSTHROUGH sub-values — emitted by _ratify_intent() in + # CognitiveTurnPipeline to distinguish the three cold-start conditions + # (ADR-0144 / ADR-0142 §Implementation debts, debt 1). All four PASSTHROUGH + # variants are normalised to "passthrough" before being folded into + # trace_hash so pre-ADR-0144 hashes remain byte-identical. + PASSTHROUGH_NO_FIELD = "passthrough_no_field" + PASSTHROUGH_NO_VOCAB = "passthrough_no_vocab" + PASSTHROUGH_NO_VERSOR = "passthrough_no_versor" @dataclass(frozen=True, slots=True) diff --git a/recognition/__init__.py b/recognition/__init__.py index 4d949163..ec7426b5 100644 --- a/recognition/__init__.py +++ b/recognition/__init__.py @@ -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", +] diff --git a/recognition/carrier.py b/recognition/carrier.py new file mode 100644 index 00000000..06a1e5b9 --- /dev/null +++ b/recognition/carrier.py @@ -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 ``:`` — 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", +] diff --git a/recognition/connector.py b/recognition/connector.py new file mode 100644 index 00000000..ba0aa8fd --- /dev/null +++ b/recognition/connector.py @@ -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 "" + 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 "" + ) + + 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"] diff --git a/tests/test_epistemic_carrier.py b/tests/test_epistemic_carrier.py new file mode 100644 index 00000000..b7268fd2 --- /dev/null +++ b/tests/test_epistemic_carrier.py @@ -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