From 5f74351729f0effdad2ee7e2905be9b5555a6af8 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 8 Jun 2026 18:42:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(epistemic):=20Q1-C=20strictly=20single-slo?= =?UTF-8?q?t=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: