diff --git a/core/epistemic_questions/__init__.py b/core/epistemic_questions/__init__.py new file mode 100644 index 00000000..f4cf69da --- /dev/null +++ b/core/epistemic_questions/__init__.py @@ -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", +] diff --git a/core/epistemic_questions/render.py b/core/epistemic_questions/render.py new file mode 100644 index 00000000..303db8dc --- /dev/null +++ b/core/epistemic_questions/render.py @@ -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", +] diff --git a/tests/test_epistemic_question_render.py b/tests/test_epistemic_question_render.py new file mode 100644 index 00000000..df1e5c68 --- /dev/null +++ b/tests/test_epistemic_question_render.py @@ -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()))