Merge pull request #666 from AssetOverflow/feat/q1-c-question-renderer

feat(epistemic): Q1-C — grounded-only question renderer (off-serving)
This commit is contained in:
Shay 2026-06-08 18:52:06 -07:00 committed by GitHub
commit 0401ec4b39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 420 additions and 0 deletions

View file

@ -0,0 +1,19 @@
"""Q1-C — the grounded-only epistemic question renderer (off-serving).
Turns an ASK :class:`~core.epistemic_disclosure.limitation.LimitationAssessment`
into an :class:`~core.epistemic_questions.render.EpistemicQuestion` under the
wrong=0 grounded-rendering invariant (scoping §2). Render only; no bus delivery,
no served disposition, no serving that is Q1-D.
"""
from __future__ import annotations
from core.epistemic_questions.render import (
EpistemicQuestion,
render_question,
)
__all__ = [
"EpistemicQuestion",
"render_question",
]

View file

@ -0,0 +1,195 @@
"""The grounded-only question renderer (Q1-C) — wrong=0-safe by construction.
This is the renderer rung of the Epistemic Disclosure ASK spine: given a
:class:`~core.epistemic_disclosure.limitation.LimitationAssessment` whose
``resolution_action == "ask_question"`` and which carries at least one typed
:class:`~core.epistemic_disclosure.limitation.MissingSlot`, produce an
:class:`EpistemicQuestion` a rendered user-facing question, or an explicit
``question_unrenderable`` verdict. Nothing here delivers, serves, or chooses a
disposition (that is Q1-D); this is *only* surface realization of the residue.
**The wrong=0 invariant (scoping §2 / session §1.5.7) the whole point.**
A question may name an entity, slot, unit, or relation only if it appears
*verbatim* in the assessment's ``grounded_terms``. When the grounded terms
lack what a question needs, degrade to a generic question or emit
``question_unrenderable`` never a named guess.
Two substrate facts force the conservative policy below:
1. ``grounded_terms`` is empty for every assessment produced today the readers
do not yet emit verbatim evidence on refusal (scoping §3, the substrate gap
Q1 must close first). So there is no grounded problem-entity to name.
2. A *missing* slot's referent is, by definition, absent from the comprehension
trace the missing thing can never appear in ``grounded_terms`` even once
readers do emit evidence. ``grounded_terms`` can only supply *context*
entities, and binding a slot to its context entity needs an alignment step
that Q1-C does not have (a later rung).
**Chosen rendering policy generic-structural, names zero problem entities.**
Because Q1-C can neither (today) read grounded context nor (ever, for the slot
itself) name the missing referent from grounded text, the only wrong=0-safe
artifact it can render is a *generic* question whose sole variable content is a
controlled English phrase for the slot's structural *type*
(``expected_unit_or_type``), drawn from the closed, audited
:data:`_CLOSED_TYPE_PHRASES` map below. Concretely the renderer:
- NEVER surfaces ``slot_name`` or ``binding_target`` these are snake_case
structural identifiers, and user-facing prose must never come from them
(limitation.py ``MissingSlot`` docstring; session §1.5.7). ``slot_name`` is
also the field most likely to *read* like a fabricated entity (``ben_rate``
the forbidden "Ben"), so it is never touched.
- NEVER prettifies a snake_case identifier into a natural-language entity no
capitalization, no possessive, no splitting on underscores.
- Translates ``expected_unit_or_type`` through the closed map only; an unmapped
type degrades to ``question_unrenderable`` (a ``renderability_gap``) rather
than dumping raw snake_case or guessing.
- Names no problem-specific entity at all. The closed type phrases ("a
whole-number count") are generic structural descriptors that assert nothing
about *this* problem distinct from problem-specific names, which would need
grounding. This is the line scoping §2 draws ("generic, all terms grounded"
vs. the fabricated "Ben").
A post-render fabrication guard (:func:`_names_only_grounded`) re-checks that
every word in the rendered text is closed-vocabulary scaffold or appears in
``grounded_terms`` defense in depth so a fabricated token can never escape even
if the template were later edited carelessly.
**Off-serving.** Imports nothing from ``generate.derivation`` or
``core.reliability_gate``; it cannot move the sealed GSM8K metric.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from core.epistemic_disclosure.limitation import (
LimitationAssessment,
MissingSlot,
)
#: Closed, audited map from a family-pinned ``expected_unit_or_type`` to a
#: controlled English phrase. Keys are exactly the slot types that ship today
#: (``core.epistemic_disclosure.limitation._FAMILY_TO_MISSING_SLOTS``). A slot
#: whose type is absent here is NOT rendered — the renderer refuses with a
#: ``renderability_gap`` rather than surfacing raw snake_case. New slot types
#: earn a phrase here (with a test) when their family lands, never by guessing.
_CLOSED_TYPE_PHRASES: dict[str, str] = {
"count_int": "a whole-number count",
"measured_unit_int": "a whole-number quantity",
}
#: The fixed question scaffold. The only hole is the closed type phrase; every
#: other word is constant, problem-independent English. It names no entity.
_TEMPLATE = (
"To answer this, one more value is still needed — {phrase} — that the "
"problem does not state. What is it?"
)
#: Machine reasons for an :class:`EpistemicQuestion`. Closed set.
_REASON_RENDERED = "rendered"
_REASON_NOT_ASK = "not_ask"
_REASON_NO_SLOT = "no_missing_slot"
_REASON_MULTI_SLOT = "multi_slot_not_supported"
_REASON_RENDERABILITY_GAP = "renderability_gap"
_REASON_FABRICATION_GUARD = "fabrication_guard"
def _tokens(text: str) -> set[str]:
"""Lowercased maximal alphabetic runs — the unit the fabrication guard checks."""
return set(re.findall(r"[a-z]+", text.lower()))
#: Every word the renderer is allowed to emit *without* grounding: the scaffold
#: words plus the words of every closed type phrase. Built once at import.
_ALLOWED_SCAFFOLD_WORDS: frozenset[str] = frozenset(
_tokens(_TEMPLATE.replace("{phrase}", " "))
| {w for phrase in _CLOSED_TYPE_PHRASES.values() for w in _tokens(phrase)}
)
@dataclass(frozen=True, slots=True)
class EpistemicQuestion:
"""The rendered ASK artifact, or an explicit unrenderable verdict.
``slot`` is the bound :class:`MissingSlot` the question is about present
whenever a slot was selected, ``None`` when the assessment carried no slot to
bind (non-ASK, or zero slots). ``text`` is the rendered question, or ``None``
when ``unrenderable``. ``reason`` is a closed-set machine string (one of the
``_REASON_*`` constants) explaining the verdict; for a renderable question it
is :data:`_REASON_RENDERED`.
"""
slot: MissingSlot | None
text: str | None
unrenderable: bool
reason: str
def _unrenderable(reason: str, slot: MissingSlot | None = None) -> EpistemicQuestion:
"""A ``question_unrenderable`` verdict with no text."""
return EpistemicQuestion(slot=slot, text=None, unrenderable=True, reason=reason)
def _names_only_grounded(text: str, grounded_terms: tuple[str, ...]) -> bool:
"""True iff every word in ``text`` is closed-vocab scaffold or grounded.
The wrong=0 guard, enforced post-render as defense in depth: a fabricated
entity (a word neither in the closed scaffold/phrase vocabulary nor verbatim
in ``grounded_terms``) makes this return ``False``, and the renderer refuses.
With today's empty ``grounded_terms`` and a fully closed-vocab template this
holds by construction; the guard exists so that can never silently change.
"""
allowed = _ALLOWED_SCAFFOLD_WORDS | _tokens(" ".join(grounded_terms))
return _tokens(text) <= allowed
def render_question(assessment: LimitationAssessment) -> EpistemicQuestion:
"""Render a single-slot generic ASK question, or refuse to render.
Strictly single-slot: Q1-C renders only when the assessment carries
*exactly one* missing slot. The fixed template asserts "one more value is
still needed" — a globally-quantified claim that exactly one value is
missing so rendering the first of several slots and ignoring the rest
would make that sentence subtly false (it would imply the single rendered
value closes the gap when others remain). Rather than weaken the template to
an honest-but-vaguer plural, a multi-slot assessment refuses with
``multi_slot_not_supported`` (slot ``None``); one-question-per-slot fan-out
is a later rung. The renderer also refuses (``question_unrenderable``) when
the assessment is not an ASK, carries no slot, or the slot's structural type
is outside the closed phrase map. It NEVER fabricates a natural-language
entity name see the module docstring for the policy and the wrong=0
rationale.
"""
if assessment.resolution_action != "ask_question":
return _unrenderable(_REASON_NOT_ASK)
if not assessment.missing_slots:
return _unrenderable(_REASON_NO_SLOT)
if len(assessment.missing_slots) > 1:
# Strictly single-slot: the template claims exactly one value is
# missing, which is false when several slots remain. Refuse rather than
# render the first and silently drop the rest.
return _unrenderable(_REASON_MULTI_SLOT, slot=None)
slot = assessment.missing_slots[0]
phrase = _CLOSED_TYPE_PHRASES.get(slot.expected_unit_or_type)
if phrase is None:
# Unknown structural type: refuse rather than surface raw snake_case.
return _unrenderable(_REASON_RENDERABILITY_GAP, slot=slot)
text = _TEMPLATE.format(phrase=phrase)
if not _names_only_grounded(text, assessment.grounded_terms):
# Unreachable by construction; the guard is the wrong=0 backstop.
return _unrenderable(_REASON_FABRICATION_GUARD, slot=slot)
return EpistemicQuestion(
slot=slot, text=text, unrenderable=False, reason=_REASON_RENDERED
)
__all__ = [
"EpistemicQuestion",
"render_question",
]

View file

@ -0,0 +1,206 @@
"""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_MULTI_SLOT,
_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_does_not_claim_all_missing_information_was_asked() -> None:
"""Two missing slots → refuse with ``multi_slot_not_supported``.
The template asserts "one more value is still needed" (exactly one). With two
slots that claim is false, so the renderer must NOT render the first and drop
the second it refuses outright, naming no slot. This is the wrong=0-honest
choice: Q1-C is strictly single-slot, not first-of-many.
"""
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)))
assert q.unrenderable
assert q.text is None
assert q.slot is None
assert q.reason == _REASON_MULTI_SLOT
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()))