"""Q1-D — off-serving ASK delivery (`QUESTION_NEEDED` tenant). Pins the delivery rung: a renderable ``ask_question`` assessment routes to ``Terminal.QUESTION_NEEDED`` carrying the Q1-C question *verbatim*; an unrenderable one takes the D2 standing-disposition fallback (``PROPOSAL_EMITTED`` if the family still proposes, else ``NO_PROGRESS``) and writes NO artifact. Each guard is written so it FAILS under the violation it nominally proves (CLAUDE.md schema-obligation discipline): a contentless ``QUESTION_NEEDED`` is structurally impossible, a delivered question is never served, and ``answer_binding`` is always the reserved ``None``. """ from __future__ import annotations import ast import json from pathlib import Path import pytest from core.epistemic_disclosure.limitation import ( LimitationAssessment, MissingSlot, ) from core.epistemic_questions import ( AnswerBinding, DeliveredQuestion, deliver_ask, emit_question, question_path, render_question, ) from core.epistemic_state import EpistemicState from generate.contemplation.findings import Terminal def _ask( *, blocking_reason: str, slots: tuple[MissingSlot, ...], kind: str = "missing_information", ) -> LimitationAssessment: """An ``ask_question`` assessment with a chosen blocking family + typed residue.""" return LimitationAssessment( limitation_kind=kind, # type: ignore[arg-type] resolution_action="ask_question", epistemic_state=EpistemicState.UNDETERMINED, owner_organ="r2_constraint", blocking_reason=blocking_reason, missing_slots=slots, ) _TOTAL_COUNT_SLOT = MissingSlot( slot_name="total_count", expected_unit_or_type="count_int", binding_target="collective_unit_total", ) _WEIGHTED_SLOT = MissingSlot( slot_name="weighted_total", expected_unit_or_type="measured_unit_int", binding_target="weighted_total_value", ) # --- the happy path: renderable ask → QUESTION_NEEDED, verbatim ----------------------- # def test_renderable_ask_delivers_question_needed() -> None: outcome = deliver_ask(_ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT,))) assert outcome.terminal == Terminal.QUESTION_NEEDED assert outcome.fallback_reason is None assert outcome.question is not None assert outcome.question.blocking_reason == "missing_total_count" assert outcome.question.owner_organ == "r2_constraint" assert outcome.question.answer_binding is None # reserved (Q2) def test_delivered_question_wraps_the_q1c_render_verbatim() -> None: """Q1-D consumes — it does not re-render. The wrapped question must be exactly what the Q1-C renderer returns for the same assessment (no second prose surface).""" assessment = _ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT,)) outcome = deliver_ask(assessment) assert outcome.question is not None assert outcome.question.question == render_question(assessment) # --- D2: the unrenderable fallback (never a contentless QUESTION_NEEDED) --------------- # def test_unrenderable_ask_falls_back_to_proposal_for_proposing_family() -> None: """Multi-slot ⇒ Q1-C refuses (multi_slot_not_supported). The family still proposes (missing_total_count is a carve-out, proposal_allowed=True), so the standing disposition is PROPOSAL_EMITTED — NOT a contentless QUESTION_NEEDED.""" outcome = deliver_ask( _ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT, _WEIGHTED_SLOT)) ) assert outcome.terminal == Terminal.PROPOSAL_EMITTED assert outcome.question is None assert outcome.fallback_reason == "multi_slot_not_supported" def test_unrenderable_ask_falls_back_to_no_progress_for_nonproposing_family() -> None: """Zero slots ⇒ Q1-C refuses (no_missing_slot). cmb_underdetermined does not propose (must_remain_refused), so the standing disposition is NO_PROGRESS.""" outcome = deliver_ask(_ask(blocking_reason="cmb_underdetermined", slots=())) assert outcome.terminal == Terminal.NO_PROGRESS assert outcome.question is None assert outcome.fallback_reason == "no_missing_slot" def test_unmapped_type_unrenderable_also_falls_back() -> None: """An unmapped structural type ⇒ Q1-C renderability_gap ⇒ fallback, never delivered.""" exotic = MissingSlot( slot_name="mystery", expected_unit_or_type="some_unmapped_type", binding_target="x" ) outcome = deliver_ask(_ask(blocking_reason="missing_total_count", slots=(exotic,))) assert outcome.terminal == Terminal.PROPOSAL_EMITTED # proposing family assert outcome.question is None assert outcome.fallback_reason == "renderability_gap" # --- structural guards: illegal DeliveredQuestion states are unrepresentable ----------- # def test_delivered_question_cannot_wrap_unrenderable() -> None: """The D2 guard, structurally: a DeliveredQuestion can NEVER wrap an unrenderable question — so a QUESTION_NEEDED terminal always carries real rendered text.""" unrenderable = render_question(_ask(blocking_reason="cmb_underdetermined", slots=())) assert unrenderable.unrenderable # precondition with pytest.raises(ValueError, match="unrenderable"): DeliveredQuestion( question=unrenderable, owner_organ="r2_constraint", blocking_reason="x" ) def test_delivered_question_is_never_served() -> None: rendered = render_question(_ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT,))) with pytest.raises(ValueError, match="never served"): DeliveredQuestion( question=rendered, owner_organ="o", blocking_reason="missing_total_count", served=True ) def test_answer_binding_is_reserved_and_rejected_in_q1d() -> None: rendered = render_question(_ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT,))) binding = AnswerBinding( target_organ="o", target_slot="total_count", binding_target="collective_unit_total", parser="int" ) with pytest.raises(ValueError, match="reserved for Q2"): DeliveredQuestion( question=rendered, owner_organ="o", blocking_reason="missing_total_count", answer_binding=binding, ) def test_deliver_ask_rejects_non_ask_assessment() -> 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", ) with pytest.raises(ValueError, match="only valid for ask_question"): deliver_ask(refuse) # --- the sink: proposal-only, idempotent, no artifact on fallback ---------------------- # def test_emit_question_writes_question_only_artifact_idempotently(tmp_path: Path) -> None: assessment = _ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT,)) path1 = emit_question(assessment, root=tmp_path) assert path1 is not None and path1.exists() doc = json.loads(path1.read_text(encoding="utf-8")) assert doc["status"] == "question_only" assert doc["served"] is False assert doc["requires_review"] is True assert doc["answer_binding"] is None assert doc["blocking_reason"] == "missing_total_count" assert doc["question"]["text"] # real rendered text, not empty first_bytes = path1.read_bytes() path2 = emit_question(assessment, root=tmp_path) # idempotent assert path2 == path1 assert path2.read_bytes() == first_bytes def test_emit_question_writes_nothing_on_unrenderable_fallback(tmp_path: Path) -> None: """No contentless artifact: an unrenderable ask writes no file and returns None.""" path = emit_question(_ask(blocking_reason="cmb_underdetermined", slots=()), root=tmp_path) assert path is None assert list(tmp_path.glob("*.json")) == [] def test_question_path_is_content_addressed(tmp_path: Path) -> None: assessment = _ask(blocking_reason="missing_total_count", slots=(_TOTAL_COUNT_SLOT,)) outcome = deliver_ask(assessment) assert outcome.question is not None p = question_path(outcome.question, tmp_path) assert p.parent == tmp_path assert p.name.endswith(".json") assert len(p.stem) == 64 # sha256 hex # --- off-serving structural guard ----------------------------------------------------- # def test_delivery_is_off_serving() -> None: """The delivery module must not import the sealed GSM8K serving substrate.""" path = Path(__file__).resolve().parents[1] / "core" / "epistemic_questions" / "delivery.py" forbidden = ("generate.derivation", "core.reliability_gate") 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"delivery.py 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"delivery.py imports forbidden serving module {alias.name}" def test_question_needed_is_a_distinct_terminal() -> None: """QUESTION_NEEDED is a real, distinct terminal — sibling of PROPOSAL_EMITTED.""" assert Terminal.QUESTION_NEEDED.value == "QUESTION_NEEDED" assert Terminal.QUESTION_NEEDED is not Terminal.PROPOSAL_EMITTED