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.
272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""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
|
|
review-gated, **question-only** ``teaching/questions`` sink. A question is an *intake
|
|
request*, NOT a capability proposal — ``question_only`` is its own lane, distinct from
|
|
the ``proposal_only`` lane of :mod:`core.comprehension_attempt.proposal`. This is the
|
|
ASK *analogue* of that emitter (and just as toothless: it never serves, never mounts,
|
|
always requires review), but the artifacts must never be conflated.
|
|
|
|
**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 review-gated, question-only ASK artifact wrapping a rendered Q1-C question.
|
|
|
|
``question_only`` is its OWN lane (an intake request), not the ``proposal_only``
|
|
lane — the analogy to :class:`~core.comprehension_attempt.proposal.FailureProposal`
|
|
is structural (toothless, review-gated, never served), never semantic.
|
|
|
|
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",
|
|
]
|