Runtime-checkable SafetyVerdict violations now replace ChatResponse.surface (and TurnEvent.surface on the main path) with a deterministic typed refusal string. Ethics violations remain audit-only. Why safety-only: safety is the universal floor (ADR-0029, never-swappable, fail-closed). Ethics is swappable per-deployment; wiring ethics into refusal would let pack-swappers silently change refusal behavior via JSON edit. Wrong coupling. Why typed refusal (not hedge injection / not re-articulation): typed refusal is deterministic, audit-detectable by prefix, and preserves replayability. Hedge injection would blur surface-preferences-driven hedging vs predicate-driven refusal. Re-articulation retry yields the same surface (planner is deterministic; no refusal-bias hint surface exists). Deferred to a future ADR. Refusal contract: - ChatResponse.surface = typed refusal string - walk_surface + articulation_surface = unchanged (audit preserved) - runtime._last_refusal_was_typed = True (next-turn evidence for no_silent_correction) - Only runtime_checkable=True violations refuse - Stub path symmetric Files: - chat/refusal.py (new) — pure refusal builder + audit helpers - chat/runtime.py — invoke build_refusal_surface after safety_verdict - tests/test_safety_refusal.py (new) — 20 tests - docs/decisions/ADR-0036-safety-refusal-policy.md (new) Verification: - 20 new tests; combined pack-layer suite 116 green - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline)
776 lines
33 KiB
Python
776 lines
33 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 List
|
|
|
|
import numpy as np
|
|
|
|
from algebra.versor import versor_condition
|
|
from chat.refusal import build_refusal_surface
|
|
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.intent_bridge import articulate_with_intent
|
|
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
|
|
|
|
|
|
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] = []
|
|
self._correction_pass = CorrectionPass()
|
|
self._last_valence: float = 0.0
|
|
|
|
@property
|
|
def session(self) -> SessionContext:
|
|
return self._context
|
|
|
|
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 _stub_response(self, field_state: FieldState) -> 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)
|
|
if refusal_surface is not None:
|
|
response_surface = refusal_surface
|
|
self._last_refusal_was_typed = True
|
|
else:
|
|
response_surface = _UNKNOWN_DOMAIN_SURFACE
|
|
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,
|
|
)
|
|
|
|
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)
|
|
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},
|
|
)
|
|
return self._stub_response(committed)
|
|
|
|
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)
|
|
|
|
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,
|
|
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)
|
|
if refusal_surface is not None:
|
|
response_surface = refusal_surface
|
|
self._last_refusal_was_typed = True
|
|
else:
|
|
response_surface = walk_surface
|
|
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,
|
|
)
|
|
self.turn_log.append(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,
|
|
)
|
|
|
|
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)
|
|
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 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``.
|