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.
199 lines
7 KiB
Python
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()))
|