Per review: 'proposal-only' described the Q1-D sink/artifact in two docstrings, conflating two distinct lanes. A question is an intake request, not a capability proposal; question_only is its own lane. The analogy to the proposal emitter is structural (toothless, review-gated, never served), never semantic. Tighten the sink/artifact docstrings and rename the sink test to *question_only* to match _QUESTION_STATUS. No behavior change — docstrings + one test name only.
239 lines
9.5 KiB
Python
239 lines
9.5 KiB
Python
"""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
|