core/core/cognition/explain.py
Shay a1a085057e
feat(w013): wire explain_last_turn() into core chat /explain REPL command (#265)
Closes W-013 wiring debt. Per Phase 2 operator decision: wire
core.cognition.explain into the live core chat REPL.

Changes:
- core/cognition/explain.py: add explain_from_intent(intent, correction_text)
  companion to explain() — same dispatch table, skips the full
  CognitiveTurnResult round-trip. Callers with only a DialogueIntent can
  use this directly.
- chat/runtime.py: add _last_intent and _last_input_text instance fields;
  store intent on every classify_intent_from_input() call (pack-grounded
  path and stub/empty-vault path); add explain_last_turn() -> str method
  that calls explain_from_intent(_last_intent, correction_text=_last_input_text).
- core/cli.py: in cmd_chat REPL loop, handle "/explain" command — calls
  runtime.explain_last_turn() and prints the canonical prompt restatement
  (or a "no prior turn" message to stderr if no turn has run yet).
- tests/test_explain_repl.py: 11 tests pinning explain_from_intent dispatch
  for all intent tags and the ChatRuntime.explain_last_turn() contract.

Per ADR-0017 (Responsive-with-Axiology): introspection is per-turn and
operator-invoked, never autonomous — the /explain command is correct
placement for this feature.
2026-05-25 06:09:49 -07:00

161 lines
5.5 KiB
Python

"""Deterministic introspection — produce a natural-language account of a turn.
``explain(result)`` returns a canonical re-statement of the turn that, when
fed back through a fresh ``CognitiveTurnPipeline``, re-routes to the same
intent classification and proposition graph, and produces a surface whose
token coverage of the original is high.
This is the ADR-0018 typed-deterministic-operator companion to the
inference walk: it inverts the articulation path back to a canonical
prompt that re-instantiates the turn. Pure dispatch on the intent tag;
no learned model; no external IO; replay-safe by construction.
Per ADR-0017 (Responsive-with-Axiology), this is a per-turn operation
invoked on a turn-id (here: directly on the CognitiveTurnResult);
introspection never runs autonomously between turns.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from generate.intent import IntentTag
if TYPE_CHECKING:
from core.cognition.result import CognitiveTurnResult
from generate.intent import DialogueIntent
# Reverse map of generate.intent._RELATION_NORMALIZE — picks one surface
# form per canonical relation so the canonical prompt re-classifies under
# the same TRANSITIVE_QUERY shape.
_CANONICAL_RELATION_SURFACE: dict[str, str] = {
"precedes": "precede",
"causes": "cause",
"grounds": "ground",
"reveals": "reveal",
"means": "mean",
"follows": "follow",
"contrasts_with": "contrast with",
"produces": "produce",
}
def _explain_definition(subject: str) -> str:
return f"What is {subject.strip()}?"
def _explain_transitive_query(subject: str, relation: str | None) -> str:
subject = subject.strip()
relation = (relation or "").strip()
if relation == "belongs_to":
return f"Where does {subject} belong?"
surface = _CANONICAL_RELATION_SURFACE.get(relation, relation)
if not surface:
return f"What is {subject}?"
return f"What does {subject} {surface}?"
def _explain_cause(subject: str) -> str:
return f"Why {subject.strip()}?"
def _explain_procedure(subject: str) -> str:
subject = subject.strip()
return f"How do I {subject}?"
def _explain_comparison(subject: str, secondary: str | None) -> str:
secondary = (secondary or "").strip() or "<missing>"
return f"Compare {subject.strip()} and {secondary}."
def _explain_correction(subject: str, correction_text: str) -> str:
# Corrections store the full proposition in ``subject`` (e.g. "wisdom
# is judgment.") so the canonical form is the discourse-marked surface
# of that proposition. Fall back to the original correction_text when
# the candidate carried it, which is the strict identity case.
body = correction_text.strip() or f"Actually {subject.strip()}"
return body
def _explain_verification(subject: str) -> str:
return f"Is {subject.strip()}?"
def _explain_recall(subject: str) -> str:
return f"Remember {subject.strip()}."
def explain(result: "CognitiveTurnResult") -> str:
"""Return a canonical natural-language account of the turn.
The returned string is the introspection round-trip's input: feeding
it back through a fresh pipeline reproduces the original turn's intent
classification and (modulo identical initial pipeline state) its
surface. Empty intent or UNKNOWN intent returns an empty string,
which the introspection lane scores as M2 failure.
"""
intent = result.intent
if intent is None:
return ""
tag = intent.tag
subject = intent.subject or ""
if tag is IntentTag.DEFINITION:
return _explain_definition(subject)
if tag is IntentTag.TRANSITIVE_QUERY:
return _explain_transitive_query(subject, intent.relation)
if tag is IntentTag.CAUSE:
return _explain_cause(subject)
if tag is IntentTag.PROCEDURE:
return _explain_procedure(subject)
if tag is IntentTag.COMPARISON:
return _explain_comparison(subject, intent.secondary_subject)
if tag is IntentTag.CORRECTION:
correction_text = ""
if result.teaching_candidate is not None:
correction_text = result.teaching_candidate.correction_text
return _explain_correction(subject, correction_text)
if tag is IntentTag.VERIFICATION:
return _explain_verification(subject)
if tag is IntentTag.RECALL:
return _explain_recall(subject)
return ""
def explain_from_intent(
intent: "DialogueIntent | None",
correction_text: str = "",
) -> str:
"""Lightweight variant for callers that have only a classified intent.
Identical dispatch to :func:`explain`; skips the full
``CognitiveTurnResult`` round-trip. ``correction_text`` is only
meaningful when the intent tag is ``CORRECTION``; callers may pass
the original user text as a reasonable approximation.
"""
if intent is None:
return ""
tag = intent.tag
subject = intent.subject or ""
if tag is IntentTag.DEFINITION:
return _explain_definition(subject)
if tag is IntentTag.TRANSITIVE_QUERY:
return _explain_transitive_query(subject, intent.relation)
if tag is IntentTag.CAUSE:
return _explain_cause(subject)
if tag is IntentTag.PROCEDURE:
return _explain_procedure(subject)
if tag is IntentTag.COMPARISON:
return _explain_comparison(subject, intent.secondary_subject)
if tag is IntentTag.CORRECTION:
return _explain_correction(subject, correction_text)
if tag is IntentTag.VERIFICATION:
return _explain_verification(subject)
if tag is IntentTag.RECALL:
return _explain_recall(subject)
return ""