From b186f073828559a88b04c4ba822f091de5b3a4ff Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 8 Jun 2026 17:20:49 -0700 Subject: [PATCH 1/2] feat(epistemic): Q1-C grounded-only question renderer (off-serving) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/epistemic_questions/__init__.py | 19 +++ core/epistemic_questions/render.py | 182 ++++++++++++++++++++++ tests/test_epistemic_question_render.py | 199 ++++++++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 core/epistemic_questions/__init__.py create mode 100644 core/epistemic_questions/render.py create mode 100644 tests/test_epistemic_question_render.py 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..11d44fe4 --- /dev/null +++ b/core/epistemic_questions/render.py @@ -0,0 +1,182 @@ +"""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_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. + + Single-slot only: the *first* missing slot is rendered; any others are + ignored (later rungs may emit one question per slot). The renderer 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) + + 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..9d00e43d --- /dev/null +++ b/tests/test_epistemic_question_render.py @@ -0,0 +1,199 @@ +"""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())) From 5f74351729f0effdad2ee7e2905be9b5555a6af8 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 8 Jun 2026 18:42:40 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(epistemic):=20Q1-C=20strictly=20single?= =?UTF-8?q?-slot=20=E2=80=94=20refuse=20multi-slot=20ASK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template asserts 'one more value is still needed' (exactly one). Rendering the first of several missing slots and ignoring the rest makes that claim subtly false. Refuse multi-slot assessments with multi_slot_not_supported (slot=None) rather than render-first-drop-rest; one-question-per-slot fan-out is a later rung. Replaces the now-contradicting first-of-many test with a test that pins the honest refusal. --- core/epistemic_questions/render.py | 25 +++++++++++++++++++------ tests/test_epistemic_question_render.py | 19 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/core/epistemic_questions/render.py b/core/epistemic_questions/render.py index 11d44fe4..303db8dc 100644 --- a/core/epistemic_questions/render.py +++ b/core/epistemic_questions/render.py @@ -92,6 +92,7 @@ _TEMPLATE = ( _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" @@ -148,17 +149,29 @@ def _names_only_grounded(text: str, grounded_terms: tuple[str, ...]) -> bool: def render_question(assessment: LimitationAssessment) -> EpistemicQuestion: """Render a single-slot generic ASK question, or refuse to render. - Single-slot only: the *first* missing slot is rendered; any others are - ignored (later rungs may emit one question per slot). The renderer 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. + 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) diff --git a/tests/test_epistemic_question_render.py b/tests/test_epistemic_question_render.py index 9d00e43d..df1e5c68 100644 --- a/tests/test_epistemic_question_render.py +++ b/tests/test_epistemic_question_render.py @@ -19,6 +19,7 @@ from core.epistemic_disclosure.limitation import ( ) 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, @@ -138,7 +139,14 @@ def test_ask_with_zero_slots_is_unrenderable() -> None: assert q.reason == _REASON_NO_SLOT -def test_multi_slot_renders_exactly_the_first_slot() -> None: +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", @@ -146,11 +154,10 @@ def test_multi_slot_renders_exactly_the_first_slot() -> None: ) 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 + 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: