feat(epistemic): Q1-D — off-serving ASK delivery (QUESTION_NEEDED tenant)
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.
This commit is contained in:
parent
0401ec4b39
commit
f88e03e53a
6 changed files with 573 additions and 29 deletions
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
266
core/epistemic_questions/delivery.py
Normal file
266
core/epistemic_questions/delivery.py
Normal file
|
|
@ -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:
|
||||
"""``<repo>/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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------------- #
|
||||
|
|
|
|||
239
tests/test_question_delivery.py
Normal file
239
tests/test_question_delivery.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue