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