core/tests/test_epistemic_question_render.py
Shay b186f07382 feat(epistemic): Q1-C grounded-only question renderer (off-serving)
Add core/epistemic_questions/ — the renderer rung of the ASK spine.
render_question(LimitationAssessment) -> EpistemicQuestion turns ASK typed
residue into a user-facing question or an explicit question_unrenderable verdict.
Render only: no bus delivery, no served disposition, no serving (that is Q1-D).

Policy (scoping §2 / session §1.5.7 wrong=0 invariant): a question may name a
problem entity only if it appears verbatim in grounded_terms. Two substrate facts
force a generic-structural policy today: grounded_terms is empty everywhere (the
readers do not yet emit verbatim evidence), and a *missing* slot's referent is by
definition absent from the trace. So the renderer:

- renders a generic question whose only variable content is a controlled English
  phrase for the slot's expected_unit_or_type, from a closed audited map;
- never surfaces slot_name or binding_target (snake_case), never prettifies an
  identifier into a natural-language entity, never names a problem entity;
- degrades unmapped types to question_unrenderable (renderability_gap) rather
  than dumping raw snake_case;
- single-slot (first slot only); non-ASK / zero-slot -> unrenderable;
- post-render fabrication guard re-checks every token is closed-vocab scaffold or
  grounded (defense in depth).

Off-serving: imports no generate.derivation / core.reliability_gate (AST-tested).
Tests: 9/9 green; smoke 90/0.
2026-06-08 17:20:49 -07:00

199 lines
7 KiB
Python

"""Q1-C — the grounded-only question renderer (off-serving).
Pins the wrong=0 grounded-rendering invariant (scoping §2 / session §1.5.7): a
rendered question may name a problem entity only if it appears verbatim in
``grounded_terms``; otherwise the renderer degrades to a generic structural
question or emits ``question_unrenderable``. ``grounded_terms`` is empty
everywhere in production today, so the renderable path is exercised here with the
closed structural-type vocabulary only.
"""
from __future__ import annotations
import ast
from pathlib import Path
from core.epistemic_disclosure.limitation import (
LimitationAssessment,
MissingSlot,
)
from core.epistemic_questions import EpistemicQuestion, render_question
from core.epistemic_questions.render import (
_REASON_NO_SLOT,
_REASON_NOT_ASK,
_REASON_RENDERABILITY_GAP,
_names_only_grounded,
)
from core.epistemic_state import EpistemicState
def _ask_assessment(
slots: tuple[MissingSlot, ...],
grounded_terms: tuple[str, ...] = (),
) -> LimitationAssessment:
"""An ``ask_question`` assessment carrying the given typed residue."""
return LimitationAssessment(
limitation_kind="missing_information",
resolution_action="ask_question",
epistemic_state=EpistemicState.UNDETERMINED,
owner_organ="r2_constraint",
blocking_reason="missing_total_count",
missing_slots=slots,
grounded_terms=grounded_terms,
)
_TOTAL_COUNT_SLOT = MissingSlot(
slot_name="total_count",
expected_unit_or_type="count_int",
binding_target="collective_unit_total",
)
# --- the wrong=0 invariant: nothing named that is not grounded -----------------
def test_ask_with_slot_and_empty_grounded_renders_generic_without_ungrounded_tokens() -> None:
"""ASK + one slot + empty grounded_terms → a generic question whose every
token is closed-vocab scaffold (nothing absent from grounded_terms is named).
"""
q = render_question(_ask_assessment((_TOTAL_COUNT_SLOT,)))
assert isinstance(q, EpistemicQuestion)
assert not q.unrenderable
assert q.slot == _TOTAL_COUNT_SLOT
assert q.text is not None
# The whole point: with empty grounded_terms, the text names nothing beyond
# the closed structural vocabulary.
assert _names_only_grounded(q.text, ())
# The closed type phrase for ``count_int`` is conveyed.
assert "whole-number count" in q.text
def test_rendered_text_never_surfaces_snake_case_identifiers() -> None:
"""Neither ``slot_name`` nor ``binding_target`` (snake_case) reaches prose."""
q = render_question(_ask_assessment((_TOTAL_COUNT_SLOT,)))
assert q.text is not None
assert "total_count" not in q.text
assert "collective_unit_total" not in q.text
assert "count_int" not in q.text # the type is translated, never raw
# --- the fabrication guard: snake_case must never become an entity -------------
def test_fabrication_guard_slot_name_ben_rate_yields_no_named_ben() -> None:
"""A slot whose ``slot_name``/``binding_target`` is ``ben_rate`` must NOT
produce a question naming "Ben" — slot_name and binding_target are never
surfaced, prettified, or capitalized.
"""
ben_slot = MissingSlot(
slot_name="ben_rate",
expected_unit_or_type="count_int", # mapped → renders
binding_target="ben_rate",
)
q = render_question(_ask_assessment((ben_slot,)))
assert q.text is not None # renders (type is mapped)
assert "Ben" not in q.text
assert "ben" not in _tokens_lower(q.text)
assert "ben_rate" not in q.text
def test_fabrication_guard_predicate_rejects_ungrounded_entity() -> None:
"""The guard predicate itself flags a fabricated name when not grounded, and
admits it once it is grounded verbatim.
"""
assert not _names_only_grounded("What is Ben rate?", ())
assert _names_only_grounded("What is Ben rate?", ("Ben", "rate"))
# --- non-ASK / no-slot / multi-slot / unmapped-type ---------------------------
def test_non_ask_assessment_is_unrenderable() -> None:
refuse = LimitationAssessment(
limitation_kind="hard_boundary",
resolution_action="refuse_known_boundary",
epistemic_state=EpistemicState.UNDETERMINED,
owner_organ="r2_constraint",
blocking_reason="non_integer_solution",
missing_slots=(_TOTAL_COUNT_SLOT,), # present, but action is not ask
)
q = render_question(refuse)
assert q.unrenderable
assert q.text is None
assert q.slot is None
assert q.reason == _REASON_NOT_ASK
def test_ask_with_zero_slots_is_unrenderable() -> None:
q = render_question(_ask_assessment(()))
assert q.unrenderable
assert q.text is None
assert q.slot is None
assert q.reason == _REASON_NO_SLOT
def test_multi_slot_renders_exactly_the_first_slot() -> None:
second = MissingSlot(
slot_name="weighted_total",
expected_unit_or_type="measured_unit_int",
binding_target="weighted_total_value",
)
q = render_question(_ask_assessment((_TOTAL_COUNT_SLOT, second)))
# Single-slot result: bound to the first slot only.
assert q.slot == _TOTAL_COUNT_SLOT
assert not q.unrenderable
assert q.text is not None
assert "whole-number count" in q.text # first slot's type, not the second's
def test_unmapped_structural_type_degrades_to_renderability_gap() -> None:
"""An unknown ``expected_unit_or_type`` refuses rather than dumping raw
snake_case — the slot is bound but no text is produced.
"""
exotic = MissingSlot(
slot_name="mystery",
expected_unit_or_type="some_unmapped_type",
binding_target="mystery_target",
)
q = render_question(_ask_assessment((exotic,)))
assert q.unrenderable
assert q.text is None
assert q.slot == exotic
assert q.reason == _REASON_RENDERABILITY_GAP
# --- off-serving structural guard ---------------------------------------------
def test_renderer_is_off_serving() -> None:
"""The renderer must not import the sealed GSM8K serving substrate."""
src = Path(__file__).resolve().parents[1] / "core" / "epistemic_questions"
forbidden = ("generate.derivation", "core.reliability_gate")
for path in src.glob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
assert not any(
node.module == f or node.module.startswith(f + ".")
for f in forbidden
), f"{path.name} imports forbidden serving module {node.module}"
elif isinstance(node, ast.Import):
for alias in node.names:
assert not any(
alias.name == f or alias.name.startswith(f + ".")
for f in forbidden
), f"{path.name} imports forbidden serving module {alias.name}"
def _tokens_lower(text: str) -> set[str]:
import re
return set(re.findall(r"[a-z]+", text.lower()))