From f88e03e53a456baaed543174770107c83509b8cf Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 8 Jun 2026 19:10:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(epistemic):=20Q1-D=20=E2=80=94=20off-servi?= =?UTF-8?q?ng=20ASK=20delivery=20(QUESTION=5FNEEDED=20tenant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth and final Q1 rung: route the Q1-C rendered question onto the contemplation bus as the QUESTION_NEEDED tenant (sibling of PROPOSAL_EMITTED), off-serving. Consumes render_question VERBATIM — Q1-D constructs no prose, so the Q1-C grounded-rendering wrong=0 guard is never bypassed by a second surface. - generate/contemplation/findings.py: add Terminal.QUESTION_NEEDED. - core/epistemic_disclosure/limitation.py: ask_question's home terminal is now QUESTION_NEEDED, making terminal_for_action total (all six actions map onto shipped terminals — the consolidation proof is now complete, not 'five of six'). - core/epistemic_questions/delivery.py: DeliveredQuestion (proposal-only artifact, cannot wrap an unrenderable question / cannot be served / answer_binding reserved None — illegal states unrepresentable), DeliveryOutcome, deliver_ask, the teaching/questions sink emitter, and AnswerBinding (reserved for Q2). Rulings honored: - D1: off-serving only — no served surface, no ask_serving_enabled, no chat wiring. - D2: an unrenderable ASK falls back to the family's standing disposition (PROPOSAL_EMITTED if it still proposes, else NO_PROGRESS) — never a contentless QUESTION_NEEDED. Enforced twice: in deliver_ask and structurally in __post_init__. - D3: Q1B_ASK_CARVE_OUT untouched — registry keeps proposal_allowed=True; both signals coexist off-serving so no operational signal is lost. - D4: sink under teaching/questions/ (sibling of teaching/proposals/). - D5: strictly single-slot — multi-slot is unrenderable, takes the D2 fallback. Tests: 16 delivery tests (happy path verbatim, both D2 fallback branches, structural guards, idempotent sink, off-serving AST) + updated the two limitation consolidation tests for the now-total terminal map. smoke 90/0, affected suites 128/0. --- core/epistemic_disclosure/limitation.py | 38 ++-- core/epistemic_questions/__init__.py | 31 ++- core/epistemic_questions/delivery.py | 266 ++++++++++++++++++++++++ generate/contemplation/findings.py | 6 + tests/test_limitation_assessment.py | 22 +- tests/test_question_delivery.py | 239 +++++++++++++++++++++ 6 files changed, 573 insertions(+), 29 deletions(-) create mode 100644 core/epistemic_questions/delivery.py create mode 100644 tests/test_question_delivery.py diff --git a/core/epistemic_disclosure/limitation.py b/core/epistemic_disclosure/limitation.py index b0e35eb9..6fa48d22 100644 --- a/core/epistemic_disclosure/limitation.py +++ b/core/epistemic_disclosure/limitation.py @@ -15,8 +15,10 @@ DERIVES each from the already-shipped failure-family registry **Hard invariant (no fourth taxonomy).** Every assessment is mechanically derived from an existing ``FailureFamily``; the *only* genuinely new resolution action is -``ask_question`` (the future Q1/ASK tenant — it is the one action with no shipped -terminal, see :func:`terminal_for_action`). +``ask_question`` (the Q1/ASK tenant). Through Q1-C it was the one action with no +shipped terminal; Q1-D adds :attr:`~generate.contemplation.findings.Terminal.QUESTION_NEEDED` +(sibling of ``PROPOSAL_EMITTED``), so :func:`terminal_for_action` is now total — every +action maps onto a shipped terminal, which is what makes this a consolidating view. **Q1-B transitional carve-out (this slice).** Two shipped families — ``missing_total_count`` and ``missing_weighted_total`` — are classified here as @@ -140,17 +142,24 @@ _KIND_TO_STATE: dict[LimitationKind, EpistemicState] = { "input_shape": EpistemicState.UNDETERMINED, } -#: The shipped contemplation ``Terminal`` each action corresponds to. ``ask_question`` -#: is ``None`` — it is the ONE action the spine adds that has no terminal yet (Q1/ASK). -#: This map is the proof that the limitation pass is a consolidating view, not a new -#: universe: five of six actions already exist as terminals. -_ACTION_TO_TERMINAL: dict[ResolutionAction, Terminal | None] = { +#: The shipped contemplation ``Terminal`` each action corresponds to. Through Q1-C +#: ``ask_question`` mapped to ``None`` (the one action the spine added with no terminal +#: yet); Q1-D ships ``QUESTION_NEEDED`` (sibling of ``PROPOSAL_EMITTED``), so the map is +#: now TOTAL — all six actions correspond to shipped terminals. This totality is the +#: proof the limitation pass is a consolidating view, not a new universe. +#: +#: NOTE: this is the action's *home* terminal "in principle". The terminal a Q1-D +#: *delivery* actually emits depends on renderability — an unrenderable ``ask_question`` +#: falls back to the family's standing disposition (the D2 guard in +#: :mod:`core.epistemic_questions.delivery`), it does NOT emit a contentless +#: ``QUESTION_NEEDED``. +_ACTION_TO_TERMINAL: dict[ResolutionAction, Terminal] = { "answer": Terminal.SOLVED_VERIFIED, "emit_proposal": Terminal.PROPOSAL_EMITTED, "refuse_known_boundary": Terminal.REFUSED_KNOWN_BOUNDARY, "report_contradiction": Terminal.CONTRADICTION_DETECTED, "step_aside": Terminal.NO_PROGRESS, - "ask_question": None, + "ask_question": Terminal.QUESTION_NEEDED, } #: **Transitional carve-out (Q1-B).** Families this slice classifies as @@ -344,12 +353,15 @@ def assess_from_attempt(attempt: ComprehensionAttempt) -> LimitationAssessment | ) -def terminal_for_action(action: ResolutionAction) -> Terminal | None: - """The shipped contemplation ``Terminal`` an action corresponds to. +def terminal_for_action(action: ResolutionAction) -> Terminal: + """The shipped contemplation ``Terminal`` an action corresponds to — total. - ``None`` only for ``ask_question`` — the one action this spine adds that has no - shipped terminal yet (the Q1/ASK tenant). Every other action maps to an existing - terminal, which is what makes this a consolidating view rather than a new taxonomy. + Every action maps to a shipped terminal; ``ask_question`` resolves to + ``QUESTION_NEEDED`` (added by Q1-D, sibling of ``PROPOSAL_EMITTED``). That totality + is what makes this a consolidating view rather than a new taxonomy. This is the + action's *home* terminal; the terminal a Q1-D delivery actually emits may differ + when the question is unrenderable (the D2 fallback — see + :mod:`core.epistemic_questions.delivery`). """ return _ACTION_TO_TERMINAL[action] diff --git a/core/epistemic_questions/__init__.py b/core/epistemic_questions/__init__.py index f4cf69da..fdb40e0b 100644 --- a/core/epistemic_questions/__init__.py +++ b/core/epistemic_questions/__init__.py @@ -1,19 +1,40 @@ -"""Q1-C — the grounded-only epistemic question renderer (off-serving). +"""The epistemic question organ — render (Q1-C) and off-serving delivery (Q1-D). -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. +Q1-C (:mod:`core.epistemic_questions.render`) 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 fabrication. + +Q1-D (:mod:`core.epistemic_questions.delivery`) routes that rendered question onto +the contemplation bus as the ``QUESTION_NEEDED`` tenant and writes a proposal-only +artifact to the ``teaching/questions`` sink — consuming the renderer verbatim, never +rendering. Off-serving: no served surface, no ``ask_serving_enabled`` yet. """ from __future__ import annotations +from core.epistemic_questions.delivery import ( + AnswerBinding, + DeliveredQuestion, + DeliveryOutcome, + default_question_root, + deliver_ask, + emit_question, + question_path, +) from core.epistemic_questions.render import ( EpistemicQuestion, render_question, ) __all__ = [ + "AnswerBinding", + "DeliveredQuestion", + "DeliveryOutcome", "EpistemicQuestion", + "default_question_root", + "deliver_ask", + "emit_question", + "question_path", "render_question", ] diff --git a/core/epistemic_questions/delivery.py b/core/epistemic_questions/delivery.py new file mode 100644 index 00000000..ade63d1e --- /dev/null +++ b/core/epistemic_questions/delivery.py @@ -0,0 +1,266 @@ +"""Q1-D — off-serving ASK delivery: route a rendered question onto the bus. + +The fourth and final Q1 rung. Given an ``ask_question`` +:class:`~core.epistemic_disclosure.limitation.LimitationAssessment`, decide the +contemplation :class:`~generate.contemplation.findings.Terminal` and, when a question +can honestly be asked, produce a :class:`DeliveredQuestion` artifact for the +proposal-only ``teaching/questions`` sink. This is the ASK analogue of the +proposal-only :mod:`core.comprehension_attempt.proposal` emitter — and just as +toothless: it never serves, never mounts, always requires review. + +**The rung separation (the steer).** Q1-D *consumes* the Q1-C +:class:`~core.epistemic_questions.render.EpistemicQuestion` — it does NOT render. +:func:`render_question` is called exactly once (in :func:`deliver_ask`) and its +result is wrapped verbatim; Q1-D constructs no user-facing prose of its own, so the +Q1-C grounded-rendering wrong=0 guard is never bypassed by a second surface. + + Q1-B: typed residue (what is missing, as typed slots) + Q1-C: renderability (can it be asked without fabricating? → EpistemicQuestion) + Q1-D: delivery (route the rendered question onto the bus) ← here + +**D2 — the delivery-side wrong=0 guard.** A contentless ``QUESTION_NEEDED`` is worse +than useless: it is a *false intake surface* (the user is invited to answer a question +that names nothing). So when :func:`render_question` returns ``unrenderable`` +(``renderability_gap`` / ``multi_slot_not_supported`` / ``no_missing_slot`` / the +fabrication backstop), :func:`deliver_ask` does NOT emit ``QUESTION_NEEDED``. It falls +back to the family's *standing disposition*: + + family still proposes (``proposal_allowed``) → ``PROPOSAL_EMITTED`` + otherwise → ``NO_PROGRESS`` + +This guard is enforced twice: in :func:`deliver_ask`'s branch, and structurally — a +:class:`DeliveredQuestion` *cannot wrap an unrenderable question* (its +``__post_init__`` refuses), so ``QUESTION_NEEDED`` is unreachable without a real, +rendered question. + +**D3 — the carve-out stays.** Q1-D delivery is OFF-SERVING. It does not flip the +``Q1B_ASK_CARVE_OUT`` (``missing_total_count`` / ``missing_weighted_total`` keep +``proposal_allowed = True`` in the registry, so the proposal pile keeps working). Both +signals coexist during the carve-out — the off-serving question artifact AND the +existing proposal — so no operational signal is lost. The flip to ``ask`` waits on a +future ``ask_serving_enabled`` gate, not on this rung. + +**D5 — single-slot only.** Q1-C refuses multi-slot rendering, so Q1-D delivers at most +one ``DeliveredQuestion`` per assessment; a multi-slot assessment is ``unrenderable`` +(``multi_slot_not_supported``) and takes the D2 fallback. No fan-out here. + +**Off-serving.** Imports nothing from ``generate.derivation`` / ``core.reliability_gate``; +it cannot move the sealed GSM8K metric, and there is NO served surface — no +``ask_serving_enabled``, no ``chat/runtime.py`` wiring. The artifacts land in the +review-gated teaching sink and nowhere else. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from core.comprehension_attempt.failure_family import family_by_name +from core.epistemic_disclosure.limitation import LimitationAssessment +from core.epistemic_questions.render import EpistemicQuestion, render_question +from generate.contemplation.findings import Terminal + +#: The sink status — the ASK analogue of the proposal emitter's ``"proposal_only"``. +#: A delivered question is review-gated intake, never a served answer. +_QUESTION_STATUS = "question_only" + + +@dataclass(frozen=True, slots=True) +class AnswerBinding: + """RESERVED for Q2 — the typed seat a future answer round-trip binds into. + + Q1-D produces NO ``AnswerBinding`` (every :class:`DeliveredQuestion` carries + ``answer_binding = None``; see that class's ``__post_init__``). The seat exists so + the Q2 binder — which must re-enter the limitation gate with augmented input, never + mutate the model mid-flight (scoping §4) — is wireable without reshaping the + artifact. ``target_slot`` / ``binding_target`` mirror the + :class:`~core.epistemic_disclosure.limitation.MissingSlot` the answer fills. + """ + + target_organ: str + target_slot: str + binding_target: str + parser: str + unit: str | None = None + + +@dataclass(frozen=True, slots=True) +class DeliveredQuestion: + """A proposal-only ASK artifact wrapping a rendered Q1-C question. + + The invariant fields are enforced in ``__post_init__`` so even a hand-constructed + instance cannot become a contentless question, a served question, or an + answer-bound (Q2) question — illegal states are unrepresentable: + + - it can never wrap an ``unrenderable`` question (the D2 guard, structurally) — + so a ``QUESTION_NEEDED`` terminal always carries real, rendered text; + - it can never be ``served`` (off-serving; ``ask_serving_enabled`` does not exist); + - it always ``requires_review``; + - its ``answer_binding`` is always ``None`` in Q1-D (the Q2 seat is reserved, unbound). + """ + + question: EpistemicQuestion + owner_organ: str | None + blocking_reason: str + answer_binding: AnswerBinding | None = None + status: str = _QUESTION_STATUS + requires_review: bool = True + served: bool = False + + def __post_init__(self) -> None: + if self.question.unrenderable or self.question.text is None: + raise ValueError( + "a DeliveredQuestion cannot wrap an unrenderable question " + f"(reason={self.question.reason!r}); the D2 fallback handles it" + ) + if self.status != _QUESTION_STATUS: + raise ValueError( + f"question status must be {_QUESTION_STATUS!r}; got {self.status!r}" + ) + if self.served: + raise ValueError("a Q1-D delivered question is never served") + if not self.requires_review: + raise ValueError("a delivered question always requires review") + if self.answer_binding is not None: + raise ValueError("answer_binding is reserved for Q2; Q1-D emits None") + + def content_hash(self) -> str: + """Deterministic content address: same question on the same blocking family and + slot always yields the same hash (idempotent sink writes). No clock, no + randomness. The rendered ``text`` is included so a template change re-addresses. + """ + slot_name = self.question.slot.slot_name if self.question.slot else "" + payload = f"{self.blocking_reason}:{slot_name}:{self.question.text}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def to_json_dict(self) -> dict[str, Any]: + slot = self.question.slot + return { + "status": self.status, + "blocking_reason": self.blocking_reason, + "owner_organ": self.owner_organ, + "question": { + "text": self.question.text, + "reason": self.question.reason, + "slot_name": slot.slot_name if slot else None, + "expected_unit_or_type": slot.expected_unit_or_type if slot else None, + "binding_target": slot.binding_target if slot else None, + }, + "answer_binding": None, # reserved (Q2) + "requires_review": self.requires_review, + "served": self.served, + } + + +@dataclass(frozen=True, slots=True) +class DeliveryOutcome: + """The result of routing one ``ask_question`` assessment. + + ``question`` is the artifact iff ``terminal is QUESTION_NEEDED`` (a renderable ask); + otherwise it is ``None`` and ``fallback_reason`` carries the unrenderable reason that + triggered the D2 standing-disposition fallback. + """ + + terminal: Terminal + question: DeliveredQuestion | None + fallback_reason: str | None + + +def _standing_disposition(blocking_reason: str) -> Terminal: + """The D2 fallback terminal for an unrenderable ask: the family's standing move. + + A family that still proposes (``proposal_allowed``) falls back to + ``PROPOSAL_EMITTED`` — its existing operational signal, preserved (D3). Anything + else (an unknown reason, or a family that does not propose) falls back to + ``NO_PROGRESS``. Never a contentless ``QUESTION_NEEDED``. + """ + family = family_by_name(blocking_reason) + if family is not None and family.proposal_allowed: + return Terminal.PROPOSAL_EMITTED + return Terminal.NO_PROGRESS + + +def deliver_ask(assessment: LimitationAssessment) -> DeliveryOutcome: + """Route an ``ask_question`` assessment to a terminal — render via Q1-C, never here. + + Renderable → ``QUESTION_NEEDED`` + a :class:`DeliveredQuestion` wrapping the Q1-C + result verbatim. Unrenderable → the D2 standing-disposition fallback (no artifact). + + Raises ``ValueError`` if called on a non-ASK assessment: the bus routes only + ``ask_question`` resolutions here, so any other action is a wiring error, not a + runtime input — fail loudly rather than silently mis-deliver. + """ + if assessment.resolution_action != "ask_question": + raise ValueError( + "deliver_ask is only valid for ask_question assessments; got " + f"{assessment.resolution_action!r}" + ) + + question = render_question(assessment) + if question.unrenderable: + return DeliveryOutcome( + terminal=_standing_disposition(assessment.blocking_reason), + question=None, + fallback_reason=question.reason, + ) + + delivered = DeliveredQuestion( + question=question, + owner_organ=assessment.owner_organ, + blocking_reason=assessment.blocking_reason, + ) + return DeliveryOutcome( + terminal=Terminal.QUESTION_NEEDED, + question=delivered, + fallback_reason=None, + ) + + +def default_question_root() -> Path: + """``/teaching/questions`` — the write-only, review-gated ASK sink. + + A sibling of ``teaching/proposals`` (D4): questions are intake requests, not + capability proposals, so they do not overload the proposal pile. + """ + return Path(__file__).resolve().parents[2] / "teaching" / "questions" + + +def question_path(delivered: DeliveredQuestion, root: Path | None = None) -> Path: + base = root if root is not None else default_question_root() + return base / f"{delivered.content_hash()}.json" + + +def emit_question( + assessment: LimitationAssessment, *, root: Path | None = None +) -> Path | None: + """Deliver an ask assessment and, iff it renders, write the artifact to the sink. + + Returns the artifact path for a ``QUESTION_NEEDED`` delivery, or ``None`` when the + ask was unrenderable and fell back (D2) — no contentless artifact is ever written. + Idempotent: the same question writes the same content-addressed path with + byte-identical content (``sort_keys``). Creates the sink directory on demand. + """ + outcome = deliver_ask(assessment) + if outcome.question is None: + return None + path = question_path(outcome.question, root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(outcome.question.to_json_dict(), indent=2, sort_keys=True), + encoding="utf-8", + ) + return path + + +__all__ = [ + "AnswerBinding", + "DeliveredQuestion", + "DeliveryOutcome", + "default_question_root", + "deliver_ask", + "emit_question", + "question_path", +] diff --git a/generate/contemplation/findings.py b/generate/contemplation/findings.py index a773a333..7bdd1e2e 100644 --- a/generate/contemplation/findings.py +++ b/generate/contemplation/findings.py @@ -19,6 +19,12 @@ class Terminal(str, Enum): REFUSED_UNSUPPORTED_FAMILY = "REFUSED_UNSUPPORTED_FAMILY" CONTRADICTION_DETECTED = "CONTRADICTION_DETECTED" PROPOSAL_EMITTED = "PROPOSAL_EMITTED" + # The ASK tenant (Q1-D): a solvable attempt blocked on missing/ambiguous *input* + # that a grounded question can intake. A SIBLING of PROPOSAL_EMITTED, never a + # subtype — a proposal offers a capability for review; a question requests a datum + # from the user. Off-serving: produced by the Q1-D delivery layer into the + # teaching/questions sink, never served until a future ask_serving_enabled gate. + QUESTION_NEEDED = "QUESTION_NEEDED" AMBIGUOUS_ORGAN = "AMBIGUOUS_ORGAN" NO_PROGRESS = "NO_PROGRESS" diff --git a/tests/test_limitation_assessment.py b/tests/test_limitation_assessment.py index c4a45b29..ebaaafb7 100644 --- a/tests/test_limitation_assessment.py +++ b/tests/test_limitation_assessment.py @@ -368,22 +368,22 @@ def test_epistemic_state_axis(): assert by_name["cmb_underdetermined"].epistemic_state == EpistemicState.UNDETERMINED -# --- the consolidation proof: actions live on shipped terminals (except ask) --------- # +# --- the consolidation proof: every action lives on a shipped terminal --------------- # -def test_actions_consolidate_onto_terminals_except_ask(): +def test_actions_consolidate_onto_terminals(): """The proof of 'consolidating view, not a fourth taxonomy': every action maps to a - shipped Terminal except ask_question (the one genuinely new action / Q1 tenant).""" + shipped Terminal. Through Q1-C ask_question was the lone exception (no terminal yet); + Q1-D ships QUESTION_NEEDED, so the map is now total — no action lacks a terminal.""" for action in VALID_ACTIONS: - terminal = terminal_for_action(action) - if action == "ask_question": - assert terminal is None - else: - assert isinstance(terminal, Terminal), action + assert isinstance(terminal_for_action(action), Terminal), action -def test_only_ask_question_is_new(): - new_actions = sorted(a for a in VALID_ACTIONS if terminal_for_action(a) is None) - assert new_actions == ["ask_question"] +def test_ask_question_resolves_to_question_needed(): + """Q1-D: ask_question's home terminal is QUESTION_NEEDED (the ASK tenant), the one + terminal the spine added to complete the consolidation.""" + assert terminal_for_action("ask_question") == Terminal.QUESTION_NEEDED + # And no action is left without a terminal. + assert [a for a in VALID_ACTIONS if terminal_for_action(a) is None] == [] # --- attempt-level classification ---------------------------------------------------- # diff --git a/tests/test_question_delivery.py b/tests/test_question_delivery.py new file mode 100644 index 00000000..846bf17a --- /dev/null +++ b/tests/test_question_delivery.py @@ -0,0 +1,239 @@ +"""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_proposal_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