feat(epistemic): Q1-C strictly single-slot — refuse multi-slot ASK

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.
This commit is contained in:
Shay 2026-06-08 18:42:40 -07:00
parent b186f07382
commit 5f74351729
2 changed files with 32 additions and 12 deletions

View file

@ -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)

View file

@ -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: