core/chat/runtime.py
Shay c9e858c266 feat(adr-0060): correction acknowledgement carries corrected-topic lemma
ADR-0053's cold-start CORRECTION surface was topic-blind: a user who
said "Actually, truth requires evidence" got a response referencing
`correction` but never `truth`.  The holdout case correction_truth_040
expected `term=['truth']` and missed — one of the architectural gaps
surfaced by the epistemology v1 curriculum unit.

ADR-0060 closes that gap by weaving the first pack-resident topical
lemma from the utterance into a fixed-template extension:

  correction received — pack-grounded ({pack_id}):
  {correction_domains}. Noted topic: {lemma} ({lemma_domains}).
  No prior turn in this session to correct yet.

Selection rule (deterministic, left-to-right token order):
  - skip stopwords: `correction`, `correct`, `be`, `have`
  - pick first pack-resident lemma
  - if none found → ADR-0053 topic-less template byte-identically

Trust-boundary invariants preserved:
  - Every visible non-template token is still lemma / pack-domain / template
  - Deterministic: same text → same bytes
  - Backward compatible: existing 15 ADR-0053 tests pass byte-identically
  - "No prior turn in this session to correct yet." trust label kept

Cognition lane lift:
  public  : intent 100% / surface 100% / term 91.7% / versor 100%   (unchanged)
  holdout : intent 100% / surface 94.7% / term 75.0%→79.2% / versor 100%

The +4.2pp matches the single-case fix exactly (correction_truth_040).
Remaining 3 holdout misses (procedure_define_010, unknown_spirit_041,
unknown_word_018) are out of scope for this ADR.

- chat/pack_grounding.py — `_extract_correction_topic_lemma` helper +
  optional `text` parameter on `pack_grounded_correction_surface`.
- chat/runtime.py — single-line call-site change to pass `text` through.
- tests/test_correction_topic_lemma.py — 14 new tests pin:
  extraction (first lemma / skips correction / skips fillers / None on
  empty / strips punctuation / case-insensitive); surface (contains
  corrected lemma / contains topic domains / degrades to ADR-0053
  byte-identically / preserves trust label / deterministic / correct
  pack_id); end-to-end (correction_truth_040 emits 'truth' / no-pack-
  lemma still grounds).

Why text-level extraction, not intent.subject:
  `intent.subject` after ADR-0049 head-noun extraction returns
  ", truth requires evidence" for the test prompt — the CORRECTION
  intent's subject-extractor preserves the post-marker tail.  Parsing
  the raw text at the surface layer is cleaner; isolates the fix;
  doesn't perturb upstream classification logic.

Lanes (regression): smoke 67 / cognition 121 / teaching 17 /
correction tests 29 (15 ADR-0053 backward-compat + 14 ADR-0060 new) —
all green.

The non-negotiable field invariant (versor_condition < 1e-6) is
unaffected: this ADR changes surface composition only.
2026-05-18 14:14:27 -07:00

1208 lines
53 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, replace
import hashlib
import json
import re
from collections.abc import Sequence
from typing import Any, List
import numpy as np
from algebra.versor import versor_condition
from chat.pack_grounding import (
pack_grounded_surface,
pack_grounded_comparison_surface,
pack_grounded_correction_surface,
PACK_ID as _COGNITION_PACK_ID,
)
from chat.teaching_grounding import (
teaching_grounded_surface,
TEACHING_CORPUS_ID as _TEACHING_CORPUS_ID,
)
from chat.refusal import (
build_hedge_prefix,
build_refusal_surface,
inject_hedge,
should_inject_hedge,
)
from chat.telemetry import (
TurnEventSink,
format_correction_event_jsonl,
format_turn_event_jsonl,
)
from chat.verdicts import TurnVerdicts
from teaching.discovery import (
extract_discovery_candidates,
format_candidate_jsonl,
)
from teaching.discovery_sink import DiscoveryCandidateSink
from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig
from core.physics.drive import DriveGradientMap, GradientField
from core.physics.energy import EnergyProfile
from core.physics.exertion import CycleCost, ExertionMeter
from core.physics.identity import (
CharacterProfile,
IdentityCheck,
IdentityScore,
TurnEvent,
)
from packs.ethics.check import EthicsCheck, EthicsContext
from packs.ethics.loader import (
DEFAULT_ETHICS_PACK as _DEFAULT_ETHICS_PACK,
EthicsPackError,
load_ethics_pack,
)
from packs.identity.loader import load_identity_manifold
from packs.safety.check import SafetyCheck, SafetyContext
from packs.safety.loader import load_safety_pack
from field.state import FieldState
from generate.articulation import ArticulationPlan, realize
from generate.dialogue import DialogueRole, classify_dialogue_blade, propose_dialogue
from generate.graph_constraint import build_graph_constraint
from generate.intent_bridge import articulate_with_intent, build_graph_from_input
from generate.proposition import FrameRegistry, Proposition, propose
from generate.result import GenerationResult
from generate.stream import generate
from generate.surface import SentenceAssembler, SentencePlan, SurfaceContext
from ingest.gate import inject
from language_packs import OOVPolicy, load_mounted_packs, load_pack, load_pack_entries
from persona.motor import PersonaMotor
from session.context import SessionContext
from session.correction import CorrectionPass
from vault.decompose import default_decomposer, default_gate
_TOKEN_RE = re.compile(r"\w+", re.UNICODE)
_SEED_ALIASES = {
"logos": "\u03bb\u03cc\u03b3\u03bf\u03c2",
"dabar": "\u05d3\u05d1\u05e8",
"or": "\u05d0\u05d5\u05e8",
"phos": "\u03c6\u03c9\u03c2",
"zoe": "\u03b6\u03c9\u03ae",
"arche": "\u1f00\u03c1\u03c7\u03ae",
"aletheia": "\u1f00\u03bb\u03ae\u03b8\u03b5\u03b9\u03b1",
}
_QUESTION_WORDS = frozenset({"what", "who", "how", "why", "when", "where", "which"})
_TERMINALS = frozenset({".", "?", ";", "!"})
_UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet."
def _energy_scalar(energy_obj) -> float:
if energy_obj is None:
return 1.0
if isinstance(energy_obj, EnergyProfile):
return float(energy_obj.raw)
try:
return float(energy_obj)
except (TypeError, ValueError):
return 1.0
def _is_question_input(raw_text: str, tokens: Sequence[str]) -> bool:
if raw_text.strip().endswith("?"):
return True
return bool(tokens and tokens[0].casefold() in _QUESTION_WORDS)
def _stable_dialogue_role(role: DialogueRole, *, raw_text: str, tokens: Sequence[str]) -> DialogueRole:
if role in {"question", "refute"} and not _is_question_input(raw_text, tokens):
return "elaborate"
return role
def _terminal_for_role(role: DialogueRole, output_language: str) -> str:
if role == "question":
return ";" if output_language == "grc" else "?"
return "."
def _terminate_surface(surface: str, *, role: DialogueRole, output_language: str) -> str:
stripped = surface.strip()
if not stripped:
return stripped
if stripped[-1] in _TERMINALS:
return stripped
return f"{stripped}{_terminal_for_role(role, output_language)}"
def _prefer_prompt_anchor(
articulation: ArticulationPlan,
filtered_tokens: Sequence[str],
*,
output_language: str,
) -> ArticulationPlan:
if output_language != "en" or len(filtered_tokens) < 2:
return articulation
content_tokens = [
token
for token in filtered_tokens
if token.casefold() not in _QUESTION_WORDS and token.casefold() not in {"is", "are", "was", "were"}
]
if not content_tokens:
return articulation
anchor = content_tokens[-1]
if anchor == articulation.subject:
return articulation
return replace(
articulation,
subject=anchor,
surface=" ".join(part for part in (anchor, articulation.predicate, articulation.object) if part),
)
@dataclass
class _StubBindingFrame:
frame_id: str
coherence_magnitude: float
region_ids: frozenset
cycle_index: int
@dataclass(frozen=True, slots=True)
class _FieldStateWithVersor:
"""Adapter exposing ``versor_condition`` for SafetyContext.
``FieldState`` itself does not carry a precomputed
``versor_condition`` attribute; it is computed on demand from
``versor_condition(state.F)``. The SafetyCheck predicate for
``preserve_versor_closure`` reads ``ctx.field_state.versor_condition``
via ``getattr``. This adapter exposes the precomputed value so the
predicate is runtime-checkable each turn.
"""
versor_condition: float
def _hash_identity_manifold(manifold) -> str:
"""Deterministic SHA-256 of the load-bearing identity-manifold fields.
ADR-0035 — feeds the ``no_identity_override`` predicate in
:class:`SafetyCheck`. The runtime never mutates ``identity_manifold``
after composition, so before- and after-turn hashes are equal by
construction; an unequal hash would indicate the predicate's exact
failure mode.
"""
payload = {
"value_axes": [
{
"axis_id": axis.axis_id,
"name": axis.name,
"direction": list(axis.direction),
"weight": axis.weight,
}
for axis in manifold.value_axes
],
"boundary_ids": sorted(manifold.boundary_ids),
"alignment_threshold": manifold.alignment_threshold,
}
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def _surface_contains_hedge(surface: str, manifold) -> bool:
"""Detect whether the realized surface emitted a hedge phrase.
Compares case-insensitively against the manifold's preferred hedge
phrases (ADR-0028). False when surface is empty. Coarse but
deterministic: the predicate downstream is observational, so
occasional false negatives are surfaced as
``acknowledge_uncertainty`` violations in audit and corrected by
refining hedge detection, not by silently passing.
"""
if not surface:
return False
prefs = getattr(manifold, "surface_preferences", None)
if prefs is None:
return False
candidates: list[str] = []
for field_name in (
"preferred_hedge_strong",
"preferred_hedge_soft",
"preferred_qualifier",
):
value = getattr(prefs, field_name, "")
if value:
candidates.append(value)
for _, hedge in getattr(prefs, "axis_hedges", ()) or ():
for sub in ("strong", "soft", "qualifier"):
value = getattr(hedge, sub, "")
if value:
candidates.append(value)
surface_fold = surface.casefold()
return any(c.casefold() in surface_fold for c in candidates if c)
def _make_trajectory_from_result(result, turn: int):
from core.physics.reasoning import TrajectoryOperator
operator = TrajectoryOperator()
states = result.trajectory or (result.final_state,)
frames = [
_StubBindingFrame(
frame_id=f"t{turn}_s{i}",
coherence_magnitude=_energy_scalar(getattr(fs, "energy", None)),
region_ids=frozenset({str(getattr(fs, "node", 0))}),
cycle_index=turn,
)
for i, fs in enumerate(states)
]
return operator.build(frames, trajectory_id=f"turn_{turn}")
@dataclass(frozen=True, slots=True)
class ChatResponse:
surface: str
proposition: Proposition
articulation: ArticulationPlan
articulation_surface: str
dialogue_role: DialogueRole
versor_condition: float
output_language: str
frame_pack: str
walk_surface: str
salience_top_k: int | None
candidates_used: int | None
vault_hits: int
identity_score: IdentityScore | None
character_profile: CharacterProfile
flagged: bool
# ADR-0023 §2 — per-transition admissibility evidence and region
# provenance flag. An empty tuple is the contract for "no
# admissibility was checked this turn" (cold start, refusal, stub).
admissibility_trace: tuple = ()
region_was_unconstrained: bool = True
# ADR-0035 — verdicts surfaced from SafetyCheck and EthicsCheck.
# ``None`` only on stub/refusal paths that bypass the turn loop.
safety_verdict: object = None
ethics_verdict: object = None
# ADR-0039 — unified TurnVerdicts bundle carrying identity / safety
# / ethics verdicts and the two remediation flags
# (refusal_emitted, hedge_injected). Typed as ``object`` to avoid
# coupling at module-resolution time; downcast at use site.
verdicts: object = None
# ADR-0048 / ADR-0050 / ADR-0052 — provenance tag for the surface's
# grounding. One of:
# "vault" — answer drawn from session vault evidence (main path).
# "pack" — answer drawn from the ratified language pack
# (cold-start DEFINITION/RECALL/COMPARISON on pack-known
# lemmas — ADR-0048 / ADR-0050).
# "teaching" — answer drawn from a reviewed teaching-chain corpus
# (cold-start CAUSE/VERIFICATION — ADR-0052).
# "none" — universal "insufficient grounding" disclosure on stub.
# The string is preserved verbatim in TurnEvent for downstream audit.
grounding_source: str = "none"
class ChatRuntime:
def __init__(
self,
pack_id: str | Sequence[str] | None = None,
*,
frame_pack: str | None = None,
config: RuntimeConfig = DEFAULT_CONFIG,
) -> None:
if pack_id is not None or frame_pack is not None:
pack_ids = (pack_id,) if isinstance(pack_id, str) else tuple(pack_id or config.input_packs)
resolved_config = RuntimeConfig(
input_packs=pack_ids,
output_language=config.output_language,
frame_pack=frame_pack or config.frame_pack,
max_tokens=config.max_tokens,
allow_cross_language_recall=config.allow_cross_language_recall,
allow_cross_language_generation=config.allow_cross_language_generation,
vault_reproject_interval=config.vault_reproject_interval,
use_salience=config.use_salience,
salience_top_k=config.salience_top_k,
inhibition_threshold=config.inhibition_threshold,
inner_loop_admissibility=config.inner_loop_admissibility,
admissibility_threshold=config.admissibility_threshold,
admissibility_mode=config.admissibility_mode,
admissibility_margin=config.admissibility_margin,
)
else:
resolved_config = config
pack_ids = tuple(config.input_packs)
self.config = resolved_config
manifests = []
manifolds = []
entries = []
for mounted_pack_id in pack_ids:
manifest, manifold = load_pack(mounted_pack_id)
manifests.append(manifest)
manifolds.append(manifold)
entries.extend(load_pack_entries(mounted_pack_id))
manifold = manifolds[0] if len(pack_ids) == 1 else load_mounted_packs(pack_ids)
self._manifests = tuple(manifests)
identity_pack_id = resolved_config.identity_pack or DEFAULT_IDENTITY_PACK
# ADR-0027 Phase 5 complete: v1 packs are ratified. Loader defaults
# to production mode (require_ratified=None -> require unless
# CORE_ALLOW_UNRATIFIED_IDENTITY=1).
identity_manifold = load_identity_manifold(identity_pack_id)
# ADR-0029: safety pack is always loaded; its boundary_ids are
# unioned into the runtime manifold. Identity packs may add
# boundaries but cannot remove safety boundaries. Failure to
# load the safety pack is fail-closed; SafetyPackError propagates
# and prevents runtime startup.
self.safety_pack = load_safety_pack()
# ADR-0033 — ethics pack composes alongside identity + safety.
# Swappable like identity; falls back to the default pack on
# load failure rather than refusing startup (safety is the
# fail-closed layer, not ethics).
ethics_pack_id = resolved_config.ethics_pack or _DEFAULT_ETHICS_PACK
try:
self.ethics_pack = load_ethics_pack(ethics_pack_id)
except EthicsPackError:
if ethics_pack_id == _DEFAULT_ETHICS_PACK:
raise
self.ethics_pack = load_ethics_pack(_DEFAULT_ETHICS_PACK)
ethics_pack_id = _DEFAULT_ETHICS_PACK
self.ethics_pack_id = ethics_pack_id
self.identity_manifold = type(identity_manifold)(
value_axes=identity_manifold.value_axes,
boundary_ids=(
identity_manifold.boundary_ids
| self.safety_pack.boundary_ids
| self.ethics_pack.commitment_ids
),
alignment_threshold=identity_manifold.alignment_threshold,
surface_preferences=identity_manifold.surface_preferences,
)
self.identity_pack_id = identity_pack_id
# Keep the generic runtime neutral. Identity/persona motivation belongs
# behind an explicit IdentityProfile contract, not the baseline chat path.
persona_motor = PersonaMotor.identity()
self._context = SessionContext(
manifold,
persona=persona_motor,
vault_reproject_interval=resolved_config.vault_reproject_interval,
)
self._frame_registry = FrameRegistry.from_pack(resolved_config.frame_pack, self._context.vocab)
self._surface_by_fold = {e.surface.casefold(): e.surface for e in entries}
self._surface_by_fold.update(_SEED_ALIASES)
self._pos_by_surface = {e.surface: (e.pos or e.part_of_speech or "X") for e in entries}
self.exertion_meter = ExertionMeter(capacity_ceiling=128.0)
self.drive_gradients = tuple(GradientField(axis=axis, magnitude=0.75) for axis in self.identity_manifold.value_axes)
self._drive_map = DriveGradientMap(gradients=self.drive_gradients)
self.character_profile = CharacterProfile.from_manifold(
self.identity_manifold,
drive_summaries={g.axis.name: g.magnitude for g in self.drive_gradients},
fatigue_index=0.0,
)
self._identity_check = IdentityCheck()
# ADR-0032 — structural safety surface. Observational at v1:
# ChatRuntime exposes ``safety_check`` for callers (audit /
# logging / future enforcement), but does not auto-invoke it in
# the turn loop. Wiring violations into refusal paths is a
# future ADR.
self.safety_check = SafetyCheck()
# ADR-0034 — structural ethics surface, sibling to SafetyCheck.
self.ethics_check = EthicsCheck()
# ADR-0035 — auto-invoke both checks at end-of-turn. The
# manifold is constructed once and never mutated, so the
# pre-turn hash is a stable property of this runtime instance.
# ``last_refusal_was_typed`` defaults True (no untyped refusals
# observed); turn-loop bookkeeping flips this on typed-refusal
# paths so the predicate has live evidence.
self._identity_manifold_hash: str = _hash_identity_manifold(
self.identity_manifold,
)
self._last_refusal_was_typed: bool = True
self.turn_log: List[TurnEvent] = []
# ADR-0040 — opt-in structured-logging sink. Default None
# preserves prior behavior; callers attach via
# ``attach_telemetry_sink``. ``_telemetry_include_content``
# gates surface / token emission per the redact-by-default
# trust boundary.
self._telemetry_sink: TurnEventSink | None = None
self._telemetry_include_content: bool = False
# ADR-0055 Phase B — opt-in DiscoveryCandidate sink. Default
# None preserves prior behavior; callers attach via
# ``attach_discovery_sink``. Candidates are *evidence*, never
# mutate the corpus or runtime state.
self._discovery_sink: DiscoveryCandidateSink | None = None
# ADR-0056 Phase C1 — opt-in contemplation pass that enriches
# each emitted DiscoveryCandidate with polarity / claim_domain /
# evidence / sub_questions before the sink writes the JSONL
# line. Default False preserves prior behavior (Phase B raw
# candidates). Toggling on does NOT mutate the corpus; the
# loop is read-only over pack + corpus + (optional) vault.
self._contemplate_discoveries: bool = False
self._correction_pass = CorrectionPass()
self._last_valence: float = 0.0
@property
def session(self) -> SessionContext:
return self._context
def attach_telemetry_sink(
self,
sink: TurnEventSink | None,
*,
include_content: bool = False,
) -> None:
"""ADR-0040 — attach a structured-logging sink.
After each turn (main or stub path), the runtime serialises
the appended ``TurnEvent`` as one JSONL line and calls
``sink.emit(line)``. Passing ``None`` detaches.
``include_content`` opts surface text and input tokens into
the emitted record. Default ``False`` preserves the
redact-by-default trust boundary (CLAUDE.md): audit pipelines
get counts, ids, and flags without raw user content.
"""
self._telemetry_sink = sink
self._telemetry_include_content = bool(include_content)
def attach_discovery_sink(
self,
sink: DiscoveryCandidateSink | None,
) -> None:
"""ADR-0055 Phase B — attach a DiscoveryCandidate sink.
After each turn, the runtime extracts zero-or-more candidates
from the most recent ``TurnEvent`` (deterministic rule firing
on the audit trail) and forwards each as one JSONL line.
Passing ``None`` detaches.
Candidates are **evidence**: emission never mutates the
active teaching corpus. Phase C's ``TeachingChainProposal``
is the only path to corpus extension and runs through
review + replay.
"""
self._discovery_sink = sink
def attach_contemplation(self, *, enabled: bool = True) -> None:
"""ADR-0056 Phase C1 — opt-in inline contemplation.
When enabled, each emitted ``DiscoveryCandidate`` is passed
through ``teaching.contemplation.contemplate`` before the
sink writes the JSONL line. The sink therefore receives an
*enriched* candidate (polarity / claim_domain / evidence /
sub_questions populated) instead of the Phase B raw record.
Read-only over pack + corpus. No corpus mutation, no clock-
time read, no LLM step. Requires ``attach_discovery_sink``
to have been called first — without a sink there is nowhere
to emit, so contemplation would do hidden work.
"""
self._contemplate_discoveries = bool(enabled)
def _emit_discovery_candidates(
self,
*,
turn_event: TurnEvent,
intent_tag: Any,
intent_subject: str | None,
grounding_source: str | None,
) -> None:
sink = self._discovery_sink
if sink is None:
return
candidates = extract_discovery_candidates(
turn_event,
intent_tag,
intent_subject,
grounding_source=grounding_source,
)
if self._contemplate_discoveries and candidates:
# Local import — keeps the contemplation module out of
# the runtime hot-path import graph for callers that
# never opt in.
from teaching.contemplation import contemplate
candidates = tuple(contemplate(c) for c in candidates)
for candidate in candidates:
sink.emit(format_candidate_jsonl(candidate))
def _emit_turn_event(self, event: TurnEvent) -> None:
"""Internal — emit one serialised line for the current event.
Called after every ``turn_log.append``. No-op when no sink
is attached. Sink errors are intentionally NOT swallowed:
a broken telemetry path should surface, not silently drop
audit signal.
"""
sink = self._telemetry_sink
if sink is None:
return
line = format_turn_event_jsonl(
event,
safety_pack_id=self.safety_pack.pack_id,
ethics_pack_id=self.ethics_pack_id,
identity_pack_id=self.identity_pack_id,
include_content=self._telemetry_include_content,
)
sink.emit(line)
def _tokenize(self, text: str) -> list[str]:
return [self._surface_by_fold.get(m.group(0).casefold(), m.group(0)) for m in _TOKEN_RE.finditer(text)]
def tokenize(self, text: str) -> list[str]:
return self._tokenize(text)
def _apply_oov_policy(self, tokens: list[str]) -> list[str]:
kept: list[str] = []
for token in tokens:
try:
self._context.vocab.get_versor(token)
kept.append(token)
except KeyError:
if all(manifest.oov_policy is OOVPolicy.FAIL_CLOSED for manifest in self._manifests):
raise
if any(manifest.oov_policy is OOVPolicy.PROPOSE_VOCAB_EXPANSION for manifest in self._manifests):
raise KeyError(f"OOV token requires vocab proposal: {token}")
kept.append(token)
return kept
def _syntactic_guard(self, tokens: tuple[str, ...]) -> list[str]:
out: list[str] = []
prev_pos: str | None = None
for token in tokens:
pos = self._pos_by_surface.get(token, "X")
if pos == prev_pos:
continue
out.append(token)
prev_pos = pos
return out
def _dialogue_reference(self) -> np.ndarray | None:
blade = self._context.last_dialogue_blade
if blade is None or float(np.linalg.norm(blade)) < 1e-8:
return None
return blade
def _apply_drive_bias(self, field_state: FieldState) -> FieldState:
"""Generic runtime keeps motivation/drive disabled.
Motivation is an identity-profile concern, not a free runtime field
mutation. Keeping this a no-op preserves the neutral baseline while
generic chat closure and cognition evals are being stabilized.
"""
return field_state
def _build_surface_context(self, identity_score, current_valence: float) -> SurfaceContext:
active = self._context.referents.active_referent()
alignment = float(identity_score.alignment) if identity_score is not None else 1.0
deviation_axes = (
frozenset(identity_score.deviation_axes)
if identity_score is not None
else frozenset()
)
prefs = self.identity_manifold.surface_preferences
# ADR-0031 — flatten the manifold's axis_hedges (tuple of
# (axis_id, AxisHedge)) into the wire-format quadruples that
# SurfaceContext carries. Order is preserved (loader emits in
# lex order); _axis_specific_phrase relies on this.
axis_hedges = tuple(
(axis_id, hedge.strong, hedge.soft, hedge.qualifier)
for axis_id, hedge in prefs.axis_hedges
)
return SurfaceContext(
active_referent_surface=active.surface if active is not None else "",
active_referent_slot=active.slot if active is not None else "neut_sg",
identity_alignment=alignment,
valence_delta=current_valence - self._last_valence,
elab_conjunction="",
hedge_threshold_strong=prefs.hedge_threshold_strong,
hedge_threshold_soft=prefs.hedge_threshold_soft,
preferred_hedge_strong=prefs.preferred_hedge_strong,
preferred_hedge_soft=prefs.preferred_hedge_soft,
claim_strength=prefs.claim_strength,
qualified_band_high=prefs.qualified_band_high,
preferred_qualifier=prefs.preferred_qualifier,
deviation_axes=deviation_axes,
axis_hedges=axis_hedges,
)
def _maybe_pack_grounded_surface(
self, text: str, gate_source: str
) -> tuple[str, str] | None:
"""Return ``(surface, grounding_source)`` or ``None``.
ADR-0048 / ADR-0050 / ADR-0052 — three reviewed sources of
cold-start grounding share this dispatcher:
- DEFINITION / RECALL → pack-grounded surface (ADR-0048)
- COMPARISON → pack-grounded surface (ADR-0050)
- CAUSE / VERIFICATION → teaching-grounded surface (ADR-0052)
Engagement conditions common to all three branches:
- the gate fired because the session vault is empty,
- ``config.output_language == "en"``,
- the classified intent has a clean subject lemma.
Returns ``None`` when no branch applies and the caller falls
through to the universal "insufficient grounding" disclosure.
The grounding_source string returned alongside the surface is
one of ``"pack"`` (ADR-0048/0050) or ``"teaching"`` (ADR-0052)
and is preserved verbatim through ChatResponse and TurnEvent
for downstream audit.
"""
if gate_source != "empty_vault":
return None
if self.config.output_language != "en":
return None
from generate.intent import IntentTag # local to avoid coupling at import time
from generate.intent_bridge import classify_intent_from_input
intent = classify_intent_from_input(text)
# ADR-0050 — COMPARISON path: deterministic side-by-side surface
# composed from both lemmas' pack semantic_domains. Engages only
# when both subject and secondary_subject are pack lemmas.
if intent.tag is IntentTag.COMPARISON:
lemma_a = (intent.subject or "").strip()
lemma_b = (intent.secondary_subject or "").strip()
if not lemma_a or not lemma_b:
return None
surface = pack_grounded_comparison_surface(lemma_a, lemma_b)
return (surface, "pack") if surface is not None else None
# ADR-0052 — teaching-grounded CAUSE / VERIFICATION. The chain
# corpus is reviewed memory; every emitted atom is either a
# lemma, a verbatim pack semantic_domains string, or a fixed
# connective from humanize_predicate.
if intent.tag in (IntentTag.CAUSE, IntentTag.VERIFICATION):
lemma = (intent.subject or "").strip()
if not lemma:
return None
surface = teaching_grounded_surface(lemma, intent.tag)
return (surface, "teaching") if surface is not None else None
# ADR-0053 — CORRECTION acknowledgement. Cold-start CORRECTION
# has no prior session turn to apply to; emit a pack-grounded
# surface that acknowledges the correction was received and
# states the missing-prior-turn constraint explicitly. The
# post-correction reviewed-teaching path (``teaching/correction.py``)
# engages only once a prior turn exists in the session.
if intent.tag is IntentTag.CORRECTION:
# ADR-0060 — pass the raw text so the acknowledgement can
# weave the corrected claim's first pack-resident topical
# lemma into the surface. Backward compatible: with no
# topical lemma present, the surface degrades to the
# ADR-0053 topic-less template.
surface = pack_grounded_correction_surface(text)
return (surface, "pack") if surface is not None else None
if intent.tag not in (IntentTag.DEFINITION, IntentTag.RECALL):
return None
lemma = (intent.subject or "").strip()
if not lemma:
return None
surface = pack_grounded_surface(lemma)
return (surface, "pack") if surface is not None else None
def _stub_response(
self,
field_state: FieldState,
*,
tokens: tuple[str, ...] = (),
pack_grounded_surface: str | None = None,
grounded_source_tag: str = "pack",
discovery_intent_tag: Any = None,
discovery_intent_subject: str | None = None,
) -> ChatResponse:
zero = np.zeros(field_state.F.shape, dtype=np.float32)
prop = Proposition(
subject="",
predicate="",
object_=None,
surface=_UNKNOWN_DOMAIN_SURFACE,
frame_id="unknown_domain",
subject_versor=zero,
predicate_versor=zero,
object_versor=None,
relation=zero,
)
art = ArticulationPlan(
subject="",
predicate="",
object=None,
surface=_UNKNOWN_DOMAIN_SURFACE,
output_language=self.config.output_language,
frame_id="unknown_domain",
)
# ADR-0035 — stub responses are exactly the ungrounded path that
# triggers ``disclose_limitations``. Surfacing verdicts here
# keeps the audit contract uniform: every ChatResponse carries
# a SafetyVerdict and EthicsVerdict.
safety_ctx = SafetyContext(
field_state=_FieldStateWithVersor(
versor_condition=float(versor_condition(field_state.F)),
),
last_refusal_was_typed=self._last_refusal_was_typed,
identity_manifold_hash_before=self._identity_manifold_hash,
identity_manifold_hash_after=_hash_identity_manifold(self.identity_manifold),
)
safety_verdict = self.safety_check.check(safety_ctx, self.safety_pack)
ethics_ctx = EthicsContext(
alignment_score=0.0,
hedge_threshold_soft=float(
self.identity_manifold.surface_preferences.hedge_threshold_soft
),
hedge_emitted=False,
grounded_in_evidence=False,
disclosure_emitted=True,
)
ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack)
# ADR-0036 — typed refusal also applies on the stub path. When
# a runtime-checkable safety boundary is violated even on the
# ungrounded surface (e.g. versor-closure failure), replace the
# user-facing ``surface`` with the deterministic typed refusal.
refusal_surface = build_refusal_surface(
safety_verdict, ethics_verdict, self.ethics_pack,
)
refusal_emitted = refusal_surface is not None
if refusal_emitted:
response_surface = refusal_surface
self._last_refusal_was_typed = True
elif pack_grounded_surface is not None:
# ADR-0048 — pack-grounded surface for cold-start DEFINITION /
# RECALL on a known pack lemma. Safety/ethics refusal still
# take priority above this branch; the pack surface only
# replaces the universal "insufficient grounding" disclosure
# when no refusal applies.
response_surface = pack_grounded_surface
else:
response_surface = _UNKNOWN_DOMAIN_SURFACE
# ADR-0048 — grounding provenance recorded for both ChatResponse
# and TurnEvent. ``"pack"`` only when we actually emit the
# pack-grounded surface (refusal does not override the source —
# refusal is a remediation tier, not a grounding source).
if pack_grounded_surface is not None and not refusal_emitted:
# ADR-0052 — preserve provenance: pack-grounded surfaces tag
# ``"pack"``, teaching-grounded surfaces tag ``"teaching"``.
grounding_source = grounded_source_tag
else:
grounding_source = "none"
# ADR-0038 — hedge injection does NOT run on the stub path
# (the unknown-domain marker is already a disclosure surface;
# prepending a hedge would be a confused double-disclosure).
# ``hedge_injected`` is therefore always False on stub paths.
verdicts_bundle = TurnVerdicts(
identity_score=None,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
refusal_emitted=refusal_emitted,
hedge_injected=False,
)
# ADR-0039 — emit a TurnEvent on stub paths too so ``turn_log``
# covers the entire turn stream for audit consumers. Only
# append when invoked from a real turn (``tokens`` is
# non-empty); defensive call sites that pass no tokens
# preserve the prior bypass-turn_log behavior.
if tokens:
stub_event = TurnEvent(
turn=max(self._context.turn - 1, 0),
input_tokens=tokens,
surface=response_surface,
walk_surface=_UNKNOWN_DOMAIN_SURFACE,
articulation_surface=_UNKNOWN_DOMAIN_SURFACE,
dialogue_role="assert",
identity_score=None,
cycle_cost_total=0.0,
vault_hits=0,
versor_condition=float(versor_condition(field_state.F)),
flagged=False,
elaboration=None,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
verdicts=verdicts_bundle,
grounding_source=grounding_source,
)
self.turn_log.append(stub_event)
self._emit_turn_event(stub_event)
# ADR-0055 Phase B — opt-in discovery candidate emission.
# Only meaningful when the caller threads classified
# intent forward (gate-fire / fall-through site). Pure
# rule firing on the just-appended TurnEvent.
if discovery_intent_tag is not None:
self._emit_discovery_candidates(
turn_event=stub_event,
intent_tag=discovery_intent_tag,
intent_subject=discovery_intent_subject,
grounding_source=grounding_source,
)
return ChatResponse(
surface=response_surface,
proposition=prop,
articulation=art,
articulation_surface=_UNKNOWN_DOMAIN_SURFACE,
dialogue_role="assert",
versor_condition=versor_condition(field_state.F),
output_language=self.config.output_language,
frame_pack=self.config.frame_pack,
walk_surface=_UNKNOWN_DOMAIN_SURFACE,
salience_top_k=None,
candidates_used=None,
vault_hits=0,
identity_score=None,
character_profile=self.character_profile,
flagged=False,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
verdicts=verdicts_bundle,
grounding_source=grounding_source,
)
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
tokens = self._tokenize(text)
filtered = self._apply_oov_policy(tokens)
if not filtered:
raise ValueError("ChatRuntime.chat() received no in-vocabulary tokens.")
probe_state = self._context.probe_ingest(filtered)
# INV-24 recall role: RECOGNITION. Feeds UnknownDomainGate — asks
# "have we seen anything like this before?", not "what is admissible
# evidence?". Session-tier SPECULATIVE memory must count here, so
# no min_status filter is applied.
direct_hits = self._context.vault.recall(probe_state.F, top_k=3)
direct_best = max((h["score"] for h in direct_hits), default=0.0)
gate_decision = default_gate.check(
direct_best,
vault=self._context.vault,
query=probe_state.F,
decomposer=default_decomposer,
)
if gate_decision.fire:
committed = self._context.commit_ingest(filtered)
empty_result = GenerationResult(tokens=(), final_state=committed, vault_hits=0)
# ADR-0048 — pack-grounded fallback for cold-start DEFINITION /
# RECALL on a known pack lemma. Only engages when the gate
# fired because the session vault is empty (``empty_vault``)
# AND the classified intent is DEFINITION or RECALL AND the
# intent's subject lemma is in the ratified cognition pack.
# Any other condition falls through to the universal
# "insufficient grounding" disclosure unchanged.
pack_result = self._maybe_pack_grounded_surface(
text, gate_decision.source
)
if pack_result is None:
pack_surface = None
pack_source_tag = "none"
else:
pack_surface, pack_source_tag = pack_result
self._context.finalize_turn(
empty_result,
tokens_in=tuple(filtered),
input_versor=committed.F,
dialogue_role="assert",
metadata={
"unknown": True,
"unknown_source": gate_decision.source,
"grounding_source": pack_source_tag if pack_surface else "none",
},
)
# ADR-0055 Phase B — thread classified intent forward only
# when a sink is attached. Discovery emission is opt-in;
# the deterministic classification used here is the same
# call ``_maybe_pack_grounded_surface`` already ran for the
# empty-vault English path, so behaviour is identical when
# no sink is attached.
discovery_intent_tag = None
discovery_intent_subject: str | None = None
if (
self._discovery_sink is not None
and gate_decision.source == "empty_vault"
and self.config.output_language == "en"
):
from generate.intent_bridge import classify_intent_from_input
_intent = classify_intent_from_input(text)
discovery_intent_tag = _intent.tag
discovery_intent_subject = _intent.subject
return self._stub_response(
committed,
tokens=tuple(filtered),
pack_grounded_surface=pack_surface,
grounded_source_tag=pack_source_tag,
discovery_intent_tag=discovery_intent_tag,
discovery_intent_subject=discovery_intent_subject,
)
field_state = self._context.commit_ingest(filtered)
field_state = self._apply_drive_bias(field_state)
reference_blade = self._dialogue_reference()
base_proposition = propose(
field_state,
None,
self._context.vocab,
self._frame_registry,
output_lang=self.config.output_language,
)
dialogue_role = _stable_dialogue_role(
classify_dialogue_blade(base_proposition.relation, reference_blade),
raw_text=text,
tokens=tokens,
)
proposition = propose_dialogue(
field_state,
self._context.vault,
self._context.vocab,
self._frame_registry,
reference_blade,
output_lang=self.config.output_language,
)
articulation = realize(proposition, self._context.vocab, output_language=self.config.output_language)
articulation = _prefer_prompt_anchor(articulation, filtered, output_language=self.config.output_language)
self._context.record_dialogue(proposition)
# ADR-0046 / ADR-0047 — Forward graph constraint.
# Build the PropositionGraph from the classified intent + articulation
# plan and convert it into an AdmissibilityRegion BEFORE generate()
# runs. An empty / fully OOV graph yields an unconstrained region
# (allowed_indices=None), which behaves identically to region=None
# via generate()'s is_unconstrained() check — so the change is a
# true no-op on inputs that produce no graph and a forward
# constraint on inputs that do. Only wired for the English path
# because the graph builder is English-specific (see intent_bridge).
forward_region = None
if self.config.forward_graph_constraint and self.config.output_language == "en":
pre_gen_graph = build_graph_from_input(text, articulation)
forward_region = build_graph_constraint(pre_gen_graph, self._context.vocab)
result = generate(
field_state,
self._context.vocab,
self._context.persona,
max_tokens=self.config.max_tokens if max_tokens is None else max_tokens,
record_trajectory=True,
vault=self._context.vault,
recall_top_k=3 if self.config.allow_cross_language_recall else 0,
output_lang=self.config.output_language,
allow_cross_language_generation=self.config.allow_cross_language_generation,
use_salience=self.config.use_salience,
salience_top_k=self.config.salience_top_k,
inhibition_threshold=self.config.inhibition_threshold,
region=forward_region,
inner_loop_admissibility=self.config.inner_loop_admissibility,
admissibility_threshold=self.config.admissibility_threshold,
admissibility_mode=self.config.admissibility_mode,
admissibility_margin=self.config.admissibility_margin,
)
# --- Articulation fidelity: replace bare S-P-O join with intent-aware surface ---
# articulate_with_intent() classifies the input intent, builds a proposition
# graph grounded on the generation result's recalled tokens, and calls the
# realize_semantic() path (13-construction realizer) that was previously
# implemented but never connected to the chat hot path.
# Falls back to the existing articulation.surface when bridge returns "".
if self.config.output_language == "en":
recalled_words = tuple(
tok for tok in (result.tokens or ()) if tok and tok.isalpha()
)
intent_surface = articulate_with_intent(text, articulation, recalled_words)
if intent_surface:
articulation = replace(articulation, surface=intent_surface)
# --- end articulation fidelity fix ---
reasoning_trajectory = _make_trajectory_from_result(result, self._context.turn)
identity_score = self._identity_check.check(reasoning_trajectory, self.identity_manifold)
flagged = identity_score.flagged
cycle_cost = CycleCost(
cycle_index=self._context.turn,
attention_cost=float(result.candidates_used or 0),
inhibition_cost=float(self.config.inhibition_threshold),
digest_cost=0.0,
trajectory_cost=float(len(result.trajectory or ())),
)
self.exertion_meter.record(cycle_cost)
fatigue = self.exertion_meter.fatigue(at_cycle=self._context.turn)
self.character_profile = CharacterProfile.from_manifold(
self.identity_manifold,
drive_summaries={g.axis.name: g.magnitude * (1.0 - fatigue.value) for g in self.drive_gradients},
fatigue_index=fatigue.value,
)
self._context.finalize_turn(
result,
tokens_in=tuple(filtered),
dialogue_role=str(dialogue_role),
)
current_valence = _energy_scalar(getattr(result.final_state, "valence", None))
surface_ctx = self._build_surface_context(identity_score, current_valence)
self._last_valence = current_valence
surface = _terminate_surface(articulation.surface, role=dialogue_role, output_language=self.config.output_language)
articulation = replace(articulation, surface=surface)
sentence_plan: SentencePlan = SentenceAssembler().assemble(
articulation,
result.tokens,
role=dialogue_role,
context=surface_ctx,
)
walk_surface = sentence_plan.surface
vault_hits = int(result.vault_hits)
# ADR-0035 — auto-invoke safety + ethics surfaces. Observational
# at v1; verdicts are attached to TurnEvent and ChatResponse for
# audit but do not gate behavior. Refusal/re-articulation
# wiring is a future ADR.
is_grounded = walk_surface != _UNKNOWN_DOMAIN_SURFACE
hedge_emitted = _surface_contains_hedge(walk_surface, self.identity_manifold)
safety_ctx = SafetyContext(
field_state=_FieldStateWithVersor(
versor_condition=float(versor_condition(result.final_state.F)),
),
last_refusal_was_typed=self._last_refusal_was_typed,
identity_manifold_hash_before=self._identity_manifold_hash,
identity_manifold_hash_after=_hash_identity_manifold(self.identity_manifold),
)
safety_verdict = self.safety_check.check(safety_ctx, self.safety_pack)
ethics_ctx = EthicsContext(
alignment_score=float(getattr(identity_score, "alignment", 0.0)),
hedge_threshold_soft=float(
self.identity_manifold.surface_preferences.hedge_threshold_soft
),
hedge_emitted=hedge_emitted,
grounded_in_evidence=is_grounded,
disclosure_emitted=not is_grounded,
)
ethics_verdict = self.ethics_check.check(ethics_ctx, self.ethics_pack)
# ADR-0036 — safety-only typed refusal. A runtime-checkable
# SafetyVerdict violation replaces the user-facing ``surface``
# with a deterministic typed refusal string. ``walk_surface``
# and ``articulation_surface`` retain the original token-walk /
# realizer evidence for audit (per the runtime surface
# contract in CLAUDE.md). Ethics violations remain audit-only.
refusal_surface = build_refusal_surface(
safety_verdict, ethics_verdict, self.ethics_pack,
)
refusal_emitted = refusal_surface is not None
hedge_injected = False
if refusal_emitted:
response_surface = refusal_surface
self._last_refusal_was_typed = True
else:
response_surface = walk_surface
# ADR-0038 — hedge injection. When an ethics commitment in
# ``ethics_pack.hedge_commitments`` fires runtime-checkable
# and the manifold has a hedge phrase configured, prepend
# the hedge to the user-facing surface. Mutually exclusive
# with refusal at the pack-schema level; this branch only
# runs when refusal did not fire. ``walk_surface`` and
# ``articulation_surface`` are preserved unchanged for
# audit (same discipline as ADR-0036).
if should_inject_hedge(ethics_verdict, self.ethics_pack):
hedge_prefix = build_hedge_prefix(self.identity_manifold)
before = response_surface
response_surface = inject_hedge(response_surface, hedge_prefix)
hedge_injected = response_surface != before
# ADR-0039 — unified TurnVerdicts bundle attached to both
# ChatResponse and TurnEvent. Audit consumers read the bundle
# instead of correlating individual fields.
verdicts_bundle = TurnVerdicts(
identity_score=identity_score,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
refusal_emitted=refusal_emitted,
hedge_injected=hedge_injected,
)
turn_event = TurnEvent(
turn=self._context.turn - 1,
input_tokens=tuple(filtered),
surface=response_surface,
walk_surface=walk_surface,
articulation_surface=articulation.surface,
dialogue_role=str(dialogue_role),
identity_score=identity_score,
cycle_cost_total=cycle_cost.total,
vault_hits=vault_hits,
versor_condition=versor_condition(result.final_state.F),
flagged=flagged,
elaboration=sentence_plan.elaboration,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
verdicts=verdicts_bundle,
grounding_source="vault",
)
self.turn_log.append(turn_event)
self._emit_turn_event(turn_event)
return ChatResponse(
surface=response_surface,
proposition=proposition,
articulation=articulation,
articulation_surface=articulation.surface,
dialogue_role=dialogue_role,
versor_condition=versor_condition(result.final_state.F),
output_language=self.config.output_language,
frame_pack=self.config.frame_pack,
walk_surface=walk_surface,
salience_top_k=result.salience_top_k,
candidates_used=result.candidates_used,
vault_hits=vault_hits,
identity_score=identity_score,
character_profile=self.character_profile,
flagged=flagged,
admissibility_trace=result.admissibility_trace,
region_was_unconstrained=result.region_was_unconstrained,
safety_verdict=safety_verdict,
ethics_verdict=ethics_verdict,
verdicts=verdicts_bundle,
grounding_source="vault",
)
def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:
return self._stub_response(field_state)
def correct(self, text: str, target_turn: int = -1, max_tokens: int | None = None) -> ChatResponse:
tokens = self._tokenize(text)
filtered = self._apply_oov_policy(tokens)
if not filtered:
raise ValueError("correct() received no in-vocabulary tokens.")
correction_state = inject(filtered, self._context.vocab)
correction_result = self._correction_pass.apply(
self._context.graph,
correction_state.F,
from_turn=target_turn,
)
self._context.apply_corrected_outputs(correction_result.records)
# ADR-0059 — emit a correction event before the regen turn so
# audit consumers can pair the backward perturbation with the
# forward turn event it produces. No-op when no sink attached.
self._emit_correction_event(correction_result, target_turn=target_turn)
regen_tokens = self._context.last_input_tokens
if not regen_tokens:
return self._stub_response(correction_state)
return self.chat(" ".join(regen_tokens), max_tokens=max_tokens)
def _emit_correction_event(
self, correction_result, *, target_turn: int,
) -> None:
"""ADR-0059 — emit one JSONL correction event to the telemetry sink.
Mirrors ``_emit_turn_event``: no-op when no sink is attached;
sink errors are intentionally NOT swallowed so a misconfigured
durable sink surfaces loudly rather than silently dropping
audit evidence.
"""
sink = self._telemetry_sink
if sink is None:
return
line = format_correction_event_jsonl(
correction_result,
target_turn=target_turn,
identity_pack_id=self.identity_pack_id,
safety_pack_id=getattr(self.safety_pack, "pack_id", ""),
ethics_pack_id=self.ethics_pack_id,
)
sink.emit(line)
def respond(self, text: str, max_tokens: int | None = None) -> str:
try:
return self.chat(text, max_tokens=max_tokens).surface
except ValueError:
return ""
async def achat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
return self.chat(text, max_tokens=max_tokens)
async def arespond(self, text: str, max_tokens: int | None = None) -> str:
try:
return (await self.achat(text, max_tokens=max_tokens)).surface
except ValueError:
return ""
# The previous ``_default_identity_manifold()`` constructor was removed as
# part of ADR-0027. Identity is now loaded from a pack at runtime via
# ``packs.identity.loader.load_identity_manifold`` using
# ``RuntimeConfig.identity_pack`` (default ``DEFAULT_IDENTITY_PACK``).
# The previously-hardcoded three axes (truthfulness / coherence /
# reverence) live in ``packs/identity/default_general_v1.json``.