diff --git a/chat/runtime.py b/chat/runtime.py index 86e261a8..e142f604 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -950,9 +950,29 @@ class ChatRuntime: # not surfaced). if self.config.accrue_realized_knowledge: self._accrue_in_turn(self._last_input_text) + response = self._maybe_surface_determination(response) self.checkpoint_engine_state() return response + def _maybe_surface_determination(self, response: ChatResponse) -> ChatResponse: + """Step B-2 — when the turn DETERMINED an answer over realized knowledge, + select that determination as the user-facing ``surface``. The realizer's + ``articulation_surface`` is retained as evidence (the determination does not + replace it). An ``Undetermined`` turn keeps the default articulation surface + (the honest "I don't know"). Off-flag turns never reach here. See the + ChatResponse selection policy in ``docs/runtime_contracts.md``. + """ + accrual = self._last_turn_accrual + if accrual is None or accrual.kind != "determined": + return response + from generate.determine import Determined, render_determination + + if not isinstance(accrual.determination, Determined): + return response # Undetermined → keep the default surface + return replace( + response, surface=render_determination(accrual.determination) + ) + def last_turn_accrual(self) -> TurnAccrual | None: """The most recent turn's inline-realization outcome (Step B), or None when accrual is off or did not complete. Introspection only — never surfaced.""" diff --git a/docs/runtime_contracts.md b/docs/runtime_contracts.md index ce0133a7..c197a94c 100644 --- a/docs/runtime_contracts.md +++ b/docs/runtime_contracts.md @@ -34,9 +34,12 @@ changing tests or silently downgrading the invariant. Current selection policy: ```text -surface = articulation_surface (when no unknown-domain gate fired) -surface = _UNKNOWN_DOMAIN_SURFACE (when the gate fired) -walk_surface = retained telemetry/evidence (always) +surface = determination_surface (when accrue_realized_knowledge AND the turn + DETERMINED an answer over realized knowledge) +surface = _UNKNOWN_DOMAIN_SURFACE (when the unknown-domain gate fired) +surface = articulation_surface (otherwise — the default) +walk_surface = retained telemetry/evidence (always) +articulation_surface = retained always (the determination surface does not replace it) ``` ### Unknown-domain gate honour @@ -53,6 +56,26 @@ gated. This closes `evals/calibration/gaps.md` Finding 2. Future realizer work may change the selection policy, but must update this document and the contract tests in the same PR. +### Determination surface (Step B-2) + +When `accrue_realized_knowledge` is enabled and a question turn is **Determined** +over realized knowledge (`generate.determine.determine` returns a `Determined`), +the user-facing `surface` is the rendered determination +(`generate.determine.render_determination`) — the engine answers *from what it +accrued in the conversation*. The basis is rendered **honestly**: SPECULATIVE +grounds (today's only case) read as "as I was told", never "verified"; D0 only +asserts `answer=True`, so the surface is an affirmation, never a fabricated or +asserted-False string. + +The realizer's `articulation_surface` is **retained unchanged** as evidence — the +determination is a user-facing *selection*, exactly like the unknown-domain gate, +not a rewrite of the realizer output. An **Undetermined** turn (refused, +open-world) keeps the default articulation surface — the honest "I don't know". +With the flag off, this path is never taken and the surface is unchanged. This is +selection only: it adds no field op, no normalization, and proposes no learning +(`accrue_in_turn` writes SESSION memory through the INV-21 vault writer; the HITL +teaching path is untouched). + ### Refusal contract (ADR-0024 Phase 2) When the inner-loop admissibility check leaves no admissible destination diff --git a/generate/determine/__init__.py b/generate/determine/__init__.py index 8a3f76ef..daf2ab3c 100644 --- a/generate/determine/__init__.py +++ b/generate/determine/__init__.py @@ -1,5 +1,6 @@ """DETERMINE — reason over realized structure → assert (as-told) / refuse (roadmap Step 4).""" from generate.determine.determine import Determined, Undetermined, determine +from generate.determine.render import render_determination -__all__ = ["Determined", "Undetermined", "determine"] +__all__ = ["Determined", "Undetermined", "determine", "render_determination"] diff --git a/generate/determine/render.py b/generate/determine/render.py new file mode 100644 index 00000000..86c965b6 --- /dev/null +++ b/generate/determine/render.py @@ -0,0 +1,26 @@ +"""Deterministic surface rendering for a Determined answer (Step B-2). + +Turns a ``Determined`` into the user-facing string the runtime surfaces. The basis is +rendered HONESTLY: SPECULATIVE grounds (today's only case) render as "as I was told", +never "verified" — only COHERENT-admissible grounds (not yet reachable) would. D0 only +ever asserts ``answer=True``, so the surface is an affirmation; there is no fabricated +or asserted-False surface to render. +""" + +from __future__ import annotations + +from generate.determine.determine import Determined + +#: Predicate → surface phrase. ``member`` reads as "is a"; the relational pack +#: predicates fall back to their lemma with underscores spaced ("less_than" → "less +#: than"). Closed and deterministic; an unknown predicate still renders legibly. +_PREDICATE_PHRASE: dict[str, str] = { + "member": "is a", +} + + +def render_determination(d: Determined) -> str: + """The user-facing surface for a Determined answer — deterministic, honest basis.""" + phrase = _PREDICATE_PHRASE.get(d.predicate, d.predicate.replace("_", " ")) + qualifier = "as I was told" if d.basis == "as_told" else "verified" + return f"Yes — {qualifier}, {d.subject} {phrase} {d.object}." diff --git a/tests/test_determination_surface.py b/tests/test_determination_surface.py new file mode 100644 index 00000000..4666176d --- /dev/null +++ b/tests/test_determination_surface.py @@ -0,0 +1,94 @@ +"""Step B-2 — surface the determination: the engine answers from accrued knowledge. + +When a question turn is Determined over realized knowledge, the user-facing ``surface`` +IS that answer (rendered honestly: "as I was told", never "verified"). The realizer's +``articulation_surface`` is retained as evidence — the determination is a selection, not +a rewrite. Undetermined and flag-off keep the default surface. This is the contract test +for the selection policy in docs/runtime_contracts.md. +""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from chat.runtime import ChatRuntime +from core.config import RuntimeConfig +from generate.determine import Determined, render_determination + + +# --------------------------------------------------------------------------- # +# render_determination — deterministic, honest basis (unit, no runtime) +# --------------------------------------------------------------------------- # + + +def test_render_member_reads_naturally() -> None: + d = Determined(answer=True, basis="as_told", predicate="member", + subject="truth", object="concept", grounds=()) + assert render_determination(d) == "Yes — as I was told, truth is a concept." + + +def test_render_relational_predicate_is_legible() -> None: + d = Determined(answer=True, basis="as_told", predicate="parent_of", + subject="alice", object="bob", grounds=()) + assert render_determination(d) == "Yes — as I was told, alice parent of bob." + + +def test_render_basis_is_honest_never_overclaims() -> None: + # SPECULATIVE grounds → "as I was told" (today's only case); COHERENT would be the + # only thing that renders "verified". The render must never overclaim. + as_told = Determined(True, "as_told", "member", "truth", "concept", ()) + assert "as I was told" in render_determination(as_told) + assert "verified" not in render_determination(as_told) + + +# --------------------------------------------------------------------------- # +# Surface selection in the live turn (integration) +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="module") +def accrued_runtime() -> ChatRuntime: + rt = ChatRuntime( + config=replace(RuntimeConfig(), accrue_realized_knowledge=True), + no_load_state=True, + ) + rt.chat("Truth is a concept.") # accrue the fact this module's questions probe + return rt + + +def _default_surface(text: str) -> str: + rt = ChatRuntime(config=RuntimeConfig(), no_load_state=True) + rt.chat("Truth is a concept.") + return rt.chat(text).surface + + +def test_determined_question_surfaces_the_answer(accrued_runtime) -> None: + response = accrued_runtime.chat("Is truth a concept?") + assert response.surface == "Yes — as I was told, truth is a concept." + # the realizer surface is retained as evidence, distinct from the selection + assert response.articulation_surface + assert response.surface != response.articulation_surface + + +def test_determined_surface_does_not_rewrite_articulation(accrued_runtime) -> None: + # The determination SELECTS the surface; the articulation_surface is the same + # evidence the default (flag-off) turn produces. + response = accrued_runtime.chat("Is truth a concept?") + off = ChatRuntime(config=RuntimeConfig(), no_load_state=True) + off.chat("Truth is a concept.") + off_response = off.chat("Is truth a concept?") + assert response.articulation_surface == off_response.articulation_surface + + +def test_undetermined_question_keeps_default_surface(accrued_runtime) -> None: + response = accrued_runtime.chat("Is truth a virtue?") # never told → Undetermined + # not an affirmation; the honest default surface (== the flag-off surface) + assert not response.surface.startswith("Yes — as I was told") + assert response.surface == _default_surface("Is truth a virtue?") + + +def test_flag_off_never_surfaces_a_determination() -> None: + surface = _default_surface("Is truth a concept?") + assert not surface.startswith("Yes — as I was told")