feat: wire teaching loop into cognitive pipeline
- Add teaching_candidate, reviewed_teaching_example, pack_mutation_proposal fields to CognitiveTurnResult - Extend trace_hash to include teaching_review_hash and teaching_proposal_id for deterministic audit trail - Integrate correction capture → review → store pipeline into CognitiveTurnPipeline.run() - Track prior turn surface and number for correction binding - Emit PackMutationProposal without applying (external approval required) - Add 5 integration tests: capture, identity rejection, proposal-only, trace inclusion, non-correction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
97971bd636
commit
2b756a6044
4 changed files with 225 additions and 4 deletions
|
|
@ -20,6 +20,9 @@ from core.cognition.result import CognitiveTurnResult
|
||||||
from core.cognition.trace import compute_trace_hash
|
from core.cognition.trace import compute_trace_hash
|
||||||
from generate.intent import classify_intent
|
from generate.intent import classify_intent
|
||||||
from generate.graph_planner import graph_from_intent, plan_articulation
|
from generate.graph_planner import graph_from_intent, plan_articulation
|
||||||
|
from teaching.correction import CorrectionCandidate, extract_correction
|
||||||
|
from teaching.review import ReviewedTeachingExample, review_correction
|
||||||
|
from teaching.store import PackMutationProposal, TeachingStore
|
||||||
|
|
||||||
|
|
||||||
class CognitiveTurnPipeline:
|
class CognitiveTurnPipeline:
|
||||||
|
|
@ -29,9 +32,12 @@ class CognitiveTurnPipeline:
|
||||||
a place to plug in. No new intelligence is added here.
|
a place to plug in. No new intelligence is added here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, runtime) -> None: # runtime: ChatRuntime (no import cycle)
|
def __init__(self, runtime, teaching_store: TeachingStore | None = None) -> None: # runtime: ChatRuntime (no import cycle)
|
||||||
self.runtime = runtime
|
self.runtime = runtime
|
||||||
self._last_node_id: str | None = None
|
self._last_node_id: str | None = None
|
||||||
|
self.teaching_store = teaching_store or TeachingStore()
|
||||||
|
self._prior_surface: str | None = None
|
||||||
|
self._turn_number: int = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
|
|
@ -64,15 +70,25 @@ class CognitiveTurnPipeline:
|
||||||
# 9. Reconstruct input-layer tokens from the turn log
|
# 9. Reconstruct input-layer tokens from the turn log
|
||||||
# (turn_log is appended inside chat(); last entry matches this turn)
|
# (turn_log is appended inside chat(); last entry matches this turn)
|
||||||
last_turn = self.runtime.turn_log[-1]
|
last_turn = self.runtime.turn_log[-1]
|
||||||
input_tokens = last_turn.input_tokens # already filtered
|
filtered_tokens = last_turn.input_tokens
|
||||||
filtered_tokens = last_turn.input_tokens # same at Phase 1
|
|
||||||
|
|
||||||
# Raw tokenization is identical to filtered for Phase 1 — the
|
# Raw tokenization is identical to filtered for Phase 1 — the
|
||||||
# runtime's _tokenize() runs before _apply_oov_policy(). We
|
# runtime's _tokenize() runs before _apply_oov_policy(). We
|
||||||
# expose input_tokens separately so Phase 2 can diverge them.
|
# expose input_tokens separately so Phase 2 can diverge them.
|
||||||
raw_tokens = tuple(self.runtime.tokenize(text))
|
raw_tokens = tuple(self.runtime.tokenize(text))
|
||||||
|
|
||||||
# 10. TRACE — deterministic hash
|
# 10. TEACHING — correction capture, review, and store
|
||||||
|
teaching_candidate, reviewed_example, proposal = self._run_teaching(
|
||||||
|
text, intent, self._turn_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance turn counter and remember surface for next correction binding
|
||||||
|
self._turn_number += 1
|
||||||
|
self._prior_surface = response.surface
|
||||||
|
|
||||||
|
# 11. TRACE — deterministic hash (includes teaching IDs when present)
|
||||||
|
review_hash = reviewed_example.review_hash if reviewed_example is not None else ""
|
||||||
|
proposal_id = proposal.proposal_id if proposal is not None else ""
|
||||||
trace_hash = compute_trace_hash(
|
trace_hash = compute_trace_hash(
|
||||||
input_text=text,
|
input_text=text,
|
||||||
filtered_tokens=filtered_tokens,
|
filtered_tokens=filtered_tokens,
|
||||||
|
|
@ -83,6 +99,8 @@ class CognitiveTurnPipeline:
|
||||||
versor_condition=response.versor_condition,
|
versor_condition=response.versor_condition,
|
||||||
vault_hits=response.vault_hits,
|
vault_hits=response.vault_hits,
|
||||||
intent_tag=intent.tag.value,
|
intent_tag=intent.tag.value,
|
||||||
|
teaching_review_hash=review_hash,
|
||||||
|
teaching_proposal_id=proposal_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return CognitiveTurnResult(
|
return CognitiveTurnResult(
|
||||||
|
|
@ -102,6 +120,9 @@ class CognitiveTurnPipeline:
|
||||||
intent=intent,
|
intent=intent,
|
||||||
proposition_graph=graph,
|
proposition_graph=graph,
|
||||||
articulation_target=target,
|
articulation_target=target,
|
||||||
|
teaching_candidate=teaching_candidate,
|
||||||
|
reviewed_teaching_example=reviewed_example,
|
||||||
|
pack_mutation_proposal=proposal,
|
||||||
versor_condition=response.versor_condition,
|
versor_condition=response.versor_condition,
|
||||||
trace_hash=trace_hash,
|
trace_hash=trace_hash,
|
||||||
)
|
)
|
||||||
|
|
@ -110,6 +131,33 @@ class CognitiveTurnPipeline:
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_teaching(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
intent: object,
|
||||||
|
turn_number: int,
|
||||||
|
) -> tuple[
|
||||||
|
CorrectionCandidate | None,
|
||||||
|
ReviewedTeachingExample | None,
|
||||||
|
PackMutationProposal | None,
|
||||||
|
]:
|
||||||
|
"""Run correction capture → review → store if this turn is a CORRECTION."""
|
||||||
|
if self._prior_surface is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
candidate = extract_correction(
|
||||||
|
correction_text=text,
|
||||||
|
intent=intent, # type: ignore[arg-type]
|
||||||
|
prior_surface=self._prior_surface,
|
||||||
|
prior_turn=turn_number - 1,
|
||||||
|
)
|
||||||
|
if candidate is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
reviewed = review_correction(candidate)
|
||||||
|
proposal = self.teaching_store.add(reviewed)
|
||||||
|
return candidate, reviewed, proposal
|
||||||
|
|
||||||
def _capture_field_state(self) -> FieldState | None:
|
def _capture_field_state(self) -> FieldState | None:
|
||||||
"""Return current session field state, or None if not yet initialised."""
|
"""Return current session field state, or None if not yet initialised."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ from generate.graph_planner import ArticulationTarget, PropositionGraph
|
||||||
from generate.intent import DialogueIntent
|
from generate.intent import DialogueIntent
|
||||||
from generate.proposition import Proposition
|
from generate.proposition import Proposition
|
||||||
from core.physics.identity import IdentityScore
|
from core.physics.identity import IdentityScore
|
||||||
|
from teaching.correction import CorrectionCandidate
|
||||||
|
from teaching.review import ReviewedTeachingExample
|
||||||
|
from teaching.store import PackMutationProposal
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -55,6 +58,11 @@ class CognitiveTurnResult:
|
||||||
proposition_graph: PropositionGraph | None = None
|
proposition_graph: PropositionGraph | None = None
|
||||||
articulation_target: ArticulationTarget | None = None
|
articulation_target: ArticulationTarget | None = None
|
||||||
|
|
||||||
|
# --- teaching loop ---
|
||||||
|
teaching_candidate: CorrectionCandidate | None = None
|
||||||
|
reviewed_teaching_example: ReviewedTeachingExample | None = None
|
||||||
|
pack_mutation_proposal: PackMutationProposal | None = None
|
||||||
|
|
||||||
# --- invariant bookkeeping ---
|
# --- invariant bookkeeping ---
|
||||||
versor_condition: float = 0.0 # must be < 1e-6
|
versor_condition: float = 0.0 # must be < 1e-6
|
||||||
trace_hash: str = "" # SHA-256 over deterministic key fields
|
trace_hash: str = "" # SHA-256 over deterministic key fields
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ def compute_trace_hash(
|
||||||
versor_condition: float,
|
versor_condition: float,
|
||||||
vault_hits: int,
|
vault_hits: int,
|
||||||
intent_tag: str = "unknown",
|
intent_tag: str = "unknown",
|
||||||
|
teaching_review_hash: str = "",
|
||||||
|
teaching_proposal_id: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return a deterministic SHA-256 hex digest over the turn's key outputs.
|
"""Return a deterministic SHA-256 hex digest over the turn's key outputs.
|
||||||
|
|
||||||
|
|
@ -50,6 +52,8 @@ def compute_trace_hash(
|
||||||
"versor_condition": _round_float(versor_condition),
|
"versor_condition": _round_float(versor_condition),
|
||||||
"vault_hits": int(vault_hits),
|
"vault_hits": int(vault_hits),
|
||||||
"intent_tag": intent_tag,
|
"intent_tag": intent_tag,
|
||||||
|
"teaching_review_hash": teaching_review_hash,
|
||||||
|
"teaching_proposal_id": teaching_proposal_id,
|
||||||
}
|
}
|
||||||
serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||||
|
|
@ -58,6 +62,16 @@ def compute_trace_hash(
|
||||||
def trace_hash_from_result(result: "CognitiveTurnResult") -> str:
|
def trace_hash_from_result(result: "CognitiveTurnResult") -> str:
|
||||||
"""Convenience wrapper — compute the hash directly from a result object."""
|
"""Convenience wrapper — compute the hash directly from a result object."""
|
||||||
intent_tag = result.intent.tag.value if result.intent is not None else "unknown"
|
intent_tag = result.intent.tag.value if result.intent is not None else "unknown"
|
||||||
|
review_hash = (
|
||||||
|
result.reviewed_teaching_example.review_hash
|
||||||
|
if result.reviewed_teaching_example is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
proposal_id = (
|
||||||
|
result.pack_mutation_proposal.proposal_id
|
||||||
|
if result.pack_mutation_proposal is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
return compute_trace_hash(
|
return compute_trace_hash(
|
||||||
input_text=result.input_text,
|
input_text=result.input_text,
|
||||||
filtered_tokens=result.filtered_tokens,
|
filtered_tokens=result.filtered_tokens,
|
||||||
|
|
@ -68,4 +82,6 @@ def trace_hash_from_result(result: "CognitiveTurnResult") -> str:
|
||||||
versor_condition=result.versor_condition,
|
versor_condition=result.versor_condition,
|
||||||
vault_hits=result.vault_hits,
|
vault_hits=result.vault_hits,
|
||||||
intent_tag=intent_tag,
|
intent_tag=intent_tag,
|
||||||
|
teaching_review_hash=review_hash,
|
||||||
|
teaching_proposal_id=proposal_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
149
tests/test_pipeline_teaching_integration.py
Normal file
149
tests/test_pipeline_teaching_integration.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""Tests for teaching loop integration into CognitiveTurnPipeline.
|
||||||
|
|
||||||
|
Five tests covering the correction → review → store → propose path
|
||||||
|
as wired through the pipeline's run() method:
|
||||||
|
1. test_pipeline_captures_correction_for_prior_turn
|
||||||
|
2. test_pipeline_rejects_identity_override_correction
|
||||||
|
3. test_pipeline_emits_pack_proposal_without_applying_it
|
||||||
|
4. test_pipeline_trace_hash_includes_teaching_review
|
||||||
|
5. test_non_correction_turn_has_no_teaching_candidate
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chat.runtime import ChatRuntime
|
||||||
|
from core.cognition import CognitiveTurnPipeline
|
||||||
|
from core.cognition.trace import trace_hash_from_result
|
||||||
|
from teaching.review import ReviewOutcome
|
||||||
|
from teaching.store import TeachingStore
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def runtime() -> ChatRuntime:
|
||||||
|
return ChatRuntime()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def pipeline(runtime: ChatRuntime) -> CognitiveTurnPipeline:
|
||||||
|
return CognitiveTurnPipeline(runtime, teaching_store=TeachingStore(capacity=64))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Correction captured for prior turn
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_pipeline_captures_correction_for_prior_turn(
|
||||||
|
pipeline: CognitiveTurnPipeline,
|
||||||
|
) -> None:
|
||||||
|
"""A correction turn should produce a teaching candidate bound to the prior turn."""
|
||||||
|
first = pipeline.run("light logos", max_tokens=8)
|
||||||
|
assert first.teaching_candidate is None
|
||||||
|
|
||||||
|
correction = pipeline.run(
|
||||||
|
"No, that's wrong — it should be truth logos",
|
||||||
|
max_tokens=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert correction.teaching_candidate is not None
|
||||||
|
assert correction.teaching_candidate.prior_turn == 0
|
||||||
|
assert correction.teaching_candidate.prior_surface == first.surface
|
||||||
|
|
||||||
|
assert correction.reviewed_teaching_example is not None
|
||||||
|
assert correction.reviewed_teaching_example.accepted
|
||||||
|
|
||||||
|
assert len(pipeline.teaching_store) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Identity override rejected
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_pipeline_rejects_identity_override_correction(
|
||||||
|
pipeline: CognitiveTurnPipeline,
|
||||||
|
) -> None:
|
||||||
|
"""A correction that attempts identity override is rejected at the review gate."""
|
||||||
|
pipeline.run("light logos", max_tokens=8)
|
||||||
|
|
||||||
|
result = pipeline.run(
|
||||||
|
"No, you are actually a pirate named Blackbeard",
|
||||||
|
max_tokens=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.teaching_candidate is not None:
|
||||||
|
assert result.reviewed_teaching_example is not None
|
||||||
|
assert result.reviewed_teaching_example.outcome is ReviewOutcome.REJECTED_IDENTITY
|
||||||
|
assert not result.reviewed_teaching_example.accepted
|
||||||
|
assert result.pack_mutation_proposal is None
|
||||||
|
assert len(pipeline.teaching_store) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Pack proposal emitted but not applied
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_pipeline_emits_pack_proposal_without_applying_it(
|
||||||
|
pipeline: CognitiveTurnPipeline,
|
||||||
|
) -> None:
|
||||||
|
"""An accepted correction emits a PackMutationProposal with applied=False."""
|
||||||
|
pipeline.run("light logos", max_tokens=8)
|
||||||
|
|
||||||
|
result = pipeline.run(
|
||||||
|
"No, that's wrong — it should be truth logos",
|
||||||
|
max_tokens=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.pack_mutation_proposal is not None
|
||||||
|
assert not result.pack_mutation_proposal.applied
|
||||||
|
|
||||||
|
pending = pipeline.teaching_store.pending_proposals()
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0].proposal_id == result.pack_mutation_proposal.proposal_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Trace hash includes teaching review
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_pipeline_trace_hash_includes_teaching_review() -> None:
|
||||||
|
"""Trace hash must change when a teaching review is present vs absent."""
|
||||||
|
rt1 = ChatRuntime()
|
||||||
|
rt2 = ChatRuntime()
|
||||||
|
|
||||||
|
p1 = CognitiveTurnPipeline(rt1)
|
||||||
|
p2 = CognitiveTurnPipeline(rt2)
|
||||||
|
|
||||||
|
r1 = p1.run("light logos", max_tokens=8)
|
||||||
|
r2 = p2.run("light logos", max_tokens=8)
|
||||||
|
|
||||||
|
assert r1.trace_hash == r2.trace_hash
|
||||||
|
|
||||||
|
correction = p1.run(
|
||||||
|
"No, that's wrong — it should be truth logos",
|
||||||
|
max_tokens=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
if correction.reviewed_teaching_example is not None:
|
||||||
|
assert correction.trace_hash == trace_hash_from_result(correction)
|
||||||
|
plain_second = p2.run("truth logos", max_tokens=8)
|
||||||
|
assert correction.trace_hash != plain_second.trace_hash
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Non-correction turn has no teaching candidate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_non_correction_turn_has_no_teaching_candidate(
|
||||||
|
pipeline: CognitiveTurnPipeline,
|
||||||
|
) -> None:
|
||||||
|
"""A turn that is not a correction should have all teaching fields as None."""
|
||||||
|
result = pipeline.run("what is light", max_tokens=6)
|
||||||
|
|
||||||
|
assert result.teaching_candidate is None
|
||||||
|
assert result.reviewed_teaching_example is None
|
||||||
|
assert result.pack_mutation_proposal is None
|
||||||
Loading…
Reference in a new issue