feat(ask): add served ASK artifact adapter

Adds the Stage 2 served ASK artifact adapter and adapter-only tests.

- Validates Q1-D DeliveredQuestion artifacts before ASK eligibility
- Uses ask_serving_enabled as a default-dark gate
- Uses the disclosure bus disposition mapping
- Fails closed to fallback surface/disposition on invalid artifacts
- Does not wire chat/runtime, telemetry, TurnEvent, ChatResponse, CLAIMS, or metrics
- Does not retire Q1B_ASK_CARVE_OUT or flip proposal_allowed
This commit is contained in:
Shay 2026-06-09 12:49:52 -07:00 committed by GitHub
parent 426adbd197
commit 4308519ca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 467 additions and 0 deletions

View file

@ -19,6 +19,9 @@ Shipped so far (all off-serving — nothing here imports ``generate.derivation``
``EpistemicState × LimitationAssessment × DisclosureClaim ServedDisposition``. ``EpistemicState × LimitationAssessment × DisclosureClaim ServedDisposition``.
Mapping scaffold only no rendering, no bus, no ``verify.py``; nothing consumes Mapping scaffold only no rendering, no bus, no ``verify.py``; nothing consumes
it yet. it yet.
* :mod:`~core.epistemic_disclosure.ask_serving` a narrow Q1-D served-ASK artifact
adapter. It validates already-rendered question artifacts and returns a typed
decision; it does not render prose and does not acquire runtime contemplation.
* :mod:`~core.epistemic_disclosure.verified_contract` (P1-A) the VERIFIED contract: * :mod:`~core.epistemic_disclosure.verified_contract` (P1-A) the VERIFIED contract:
the obligation, the proof shape, the validator, and the single sanctioned route to the obligation, the proof shape, the validator, and the single sanctioned route to
``EpistemicState.VERIFIED`` / ``DisclosureClaim.VERIFIED``. Contract only no ``EpistemicState.VERIFIED`` / ``DisclosureClaim.VERIFIED``. Contract only no
@ -27,6 +30,10 @@ Shipped so far (all off-serving — nothing here imports ``generate.derivation``
from __future__ import annotations from __future__ import annotations
from core.epistemic_disclosure.ask_serving import (
ServedAskDecision,
evaluate_served_ask,
)
from core.epistemic_disclosure.disclosure_claim import ( from core.epistemic_disclosure.disclosure_claim import (
DEFAULT_DISCLOSURE_CLAIM, DEFAULT_DISCLOSURE_CLAIM,
DisclosureClaim, DisclosureClaim,
@ -64,6 +71,7 @@ __all__ = [
"LimitationKind", "LimitationKind",
"MissingSlot", "MissingSlot",
"ResolutionAction", "ResolutionAction",
"ServedAskDecision",
"ServedDisposition", "ServedDisposition",
"VerificationObligation", "VerificationObligation",
"VerificationProof", "VerificationProof",
@ -73,6 +81,7 @@ __all__ = [
"assess_from_family", "assess_from_family",
"choose_served_disposition", "choose_served_disposition",
"disclosure_for_verification", "disclosure_for_verification",
"evaluate_served_ask",
"evaluate_verification", "evaluate_verification",
"terminal_for_action", "terminal_for_action",
] ]

View file

@ -0,0 +1,171 @@
"""Stage 2 ASK served-surface artifact adapter.
This module is intentionally narrow: it validates a pre-rendered Q1-D
``DeliveredQuestion`` artifact and decides whether that artifact is eligible to
be exposed as a served ASK/QUESTION_NEEDED surface. It does not acquire
contemplation results from runtime and does not render question prose.
Validation enforces the Q1-D artifact contract:
- top-level JSON object only;
- ``status == "question_only"``;
- ``requires_review is True``;
- ``served is False``;
- ``answer_binding`` is absent or ``None``;
- ``question`` is an object;
- ``question.text`` is a non-empty string;
- ``question.slot_name`` is a non-empty string;
- ``question_path`` exists on disk and differs from ``proposal_path``.
Any validation failure fails closed to the caller's fallback surface and
standing disposition. The served text is consumed from the artifact exactly; no
runtime prose construction or mutation happens here.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from core.epistemic_disclosure.disposition import ServedDisposition, choose_served_disposition
from core.epistemic_disclosure.limitation import LimitationAssessment
from core.epistemic_questions.serving_gate import ask_serving_enabled
from core.epistemic_state import EpistemicState
_MISSING = object()
@dataclass(frozen=True, slots=True)
class ServedAskDecision:
"""The adapter's served-ASK decision."""
served: bool
terminal: str
surface: str
disposition: ServedDisposition
def _terminal_value(contemplation_result: Any) -> str:
terminal = getattr(contemplation_result, "terminal", None)
if terminal is None:
return "NO_PROGRESS"
return str(getattr(terminal, "value", terminal))
def _fallback_disposition(terminal: str) -> ServedDisposition:
if terminal == "PROPOSAL_EMITTED":
return ServedDisposition.PROPOSE
if terminal == "SOLVED_VERIFIED":
return ServedDisposition.COMMIT
return ServedDisposition.REFUSE
def _fallback_decision(contemplation_result: Any, fallback_surface: str) -> ServedAskDecision:
terminal = _terminal_value(contemplation_result)
return ServedAskDecision(
served=False,
terminal=terminal,
surface=fallback_surface,
disposition=_fallback_disposition(terminal),
)
def _validate_question_artifact(data: Any, *, question_path: Path, proposal_path: Any) -> str | None:
"""Return the valid question text, or ``None`` for any contract violation."""
if not isinstance(data, dict):
return None
if data.get("status") != "question_only":
return None
if data.get("requires_review") is not True:
return None
served = data.get("served", _MISSING)
if served is _MISSING or served is not False:
return None
answer_binding = data.get("answer_binding", _MISSING)
if answer_binding is not _MISSING and answer_binding is not None:
return None
question = data.get("question")
if not isinstance(question, dict):
return None
text = question.get("text")
if not isinstance(text, str) or not text.strip():
return None
slot_name = question.get("slot_name")
if not isinstance(slot_name, str) or not slot_name.strip():
return None
if proposal_path is not None and str(question_path) == str(proposal_path):
return None
return text.strip()
def evaluate_served_ask(
config: Any,
contemplation_result: Any,
fallback_surface: str,
) -> ServedAskDecision:
"""Evaluate whether a Q1-D question artifact may be surfaced as ASK.
This is a bus/disposition adapter, not a renderer and not the runtime
acquisition path. The caller supplies a contemplation result that already
points to a delivered question artifact. When the gate is disabled or any
artifact invariant fails, the adapter returns the fallback surface and the
standing fallback disposition.
"""
if not ask_serving_enabled(config):
return _fallback_decision(contemplation_result, fallback_surface)
if _terminal_value(contemplation_result) != "QUESTION_NEEDED":
return _fallback_decision(contemplation_result, fallback_surface)
question_path_value = getattr(contemplation_result, "question_path", None)
proposal_path_value = getattr(contemplation_result, "proposal_path", None)
if not question_path_value or question_path_value == proposal_path_value:
return _fallback_decision(contemplation_result, fallback_surface)
question_path = Path(question_path_value)
if not question_path.is_file():
return _fallback_decision(contemplation_result, fallback_surface)
try:
payload = json.loads(question_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return _fallback_decision(contemplation_result, fallback_surface)
question_text = _validate_question_artifact(
payload,
question_path=question_path,
proposal_path=proposal_path_value,
)
if question_text is None:
return _fallback_decision(contemplation_result, fallback_surface)
limitation = LimitationAssessment(
limitation_kind="missing_information",
resolution_action="ask_question",
epistemic_state=EpistemicState.UNDETERMINED,
owner_organ=payload.get("owner_organ"),
blocking_reason=str(payload.get("blocking_reason", "")),
)
disposition = choose_served_disposition(
epistemic_state=EpistemicState.UNDETERMINED,
limitation=limitation,
)
return ServedAskDecision(
served=True,
terminal="QUESTION_NEEDED",
surface=question_text,
disposition=disposition,
)
__all__ = ["ServedAskDecision", "evaluate_served_ask"]

View file

@ -0,0 +1,287 @@
"""Focused tests for the Stage 2 served ASK artifact adapter.
These tests intentionally avoid ``chat.runtime``. This slice is adapter-only:
it validates Q1-D question artifacts and returns a typed decision, but does not
wire runtime acquisition of ``ContemplationResult``.
"""
from __future__ import annotations
import ast
import json
from pathlib import Path
from core.config import RuntimeConfig
from core.epistemic_disclosure import ServedAskDecision, evaluate_served_ask
from core.epistemic_disclosure.disposition import ServedDisposition, choose_served_disposition
class DummyTerminal:
def __init__(self, value: str):
self.value = value
class DummyContemplationResult:
def __init__(
self,
terminal: str,
*,
question_path: str | None = None,
proposal_path: str | None = None,
family: str | None = None,
) -> None:
self.terminal = DummyTerminal(terminal)
self.question_path = question_path
self.proposal_path = proposal_path
self.family = family
def _write_artifact(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data), encoding="utf-8")
def _valid_payload(text: str = "How many crates are left?") -> dict:
return {
"status": "question_only",
"blocking_reason": "missing_total_count",
"owner_organ": "r2_constraint",
"question": {
"text": text,
"reason": "missing_total_count",
"slot_name": "total_count",
"expected_unit_or_type": "count_int",
"binding_target": "collective_unit_total",
},
"answer_binding": None,
"requires_review": True,
"served": False,
}
def _question_result(q_path: Path, p_path: Path | None = None) -> DummyContemplationResult:
return DummyContemplationResult(
"QUESTION_NEEDED",
question_path=str(q_path),
proposal_path=str(p_path) if p_path is not None else None,
)
def test_ask_serving_disabled_preserves_existing_fallback_surface(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
_write_artifact(q_path, _valid_payload())
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=False),
_question_result(q_path),
"fallback",
)
assert isinstance(decision, ServedAskDecision)
assert decision.served is False
assert decision.terminal == "QUESTION_NEEDED"
assert decision.surface == "fallback"
assert decision.disposition is ServedDisposition.REFUSE
def test_ask_serving_enabled_surfaces_question_needed_from_artifact(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
p_path = tmp_path / "proposals" / "p.json"
_write_artifact(q_path, _valid_payload("How many crates are left?"))
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
_question_result(q_path, p_path),
"fallback",
)
assert decision.served is True
assert decision.terminal == "QUESTION_NEEDED"
assert decision.surface == "How many crates are left?"
assert decision.disposition is ServedDisposition.ASK
def test_served_ask_uses_governance_disposition_bus(monkeypatch, tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
_write_artifact(q_path, _valid_payload())
calls = []
def traced_choose_served_disposition(*args, **kwargs):
calls.append((args, kwargs))
return choose_served_disposition(*args, **kwargs)
monkeypatch.setattr(
"core.epistemic_disclosure.ask_serving.choose_served_disposition",
traced_choose_served_disposition,
)
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
_question_result(q_path),
"fallback",
)
assert decision.served is True
assert decision.disposition is ServedDisposition.ASK
assert len(calls) == 1
assert calls[0][1]["limitation"].resolution_action == "ask_question"
def test_question_path_must_not_equal_proposal_path(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "same.json"
_write_artifact(q_path, _valid_payload())
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
_question_result(q_path, q_path),
"fallback",
)
assert decision.served is False
assert decision.surface == "fallback"
def test_non_question_needed_terminal_preserves_proposal_signal(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
_write_artifact(q_path, _valid_payload())
result = DummyContemplationResult(
"PROPOSAL_EMITTED",
question_path=str(q_path),
proposal_path=str(tmp_path / "proposals" / "p.json"),
)
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
result,
"fallback",
)
assert decision.served is False
assert decision.terminal == "PROPOSAL_EMITTED"
assert decision.surface == "fallback"
assert decision.disposition is ServedDisposition.PROPOSE
def test_missing_or_unreadable_question_artifact_fails_closed(tmp_path: Path) -> None:
missing = tmp_path / "questions" / "missing.json"
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
_question_result(missing),
"fallback",
)
assert decision.served is False
assert decision.surface == "fallback"
def test_malformed_question_artifact_fails_closed(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
q_path.parent.mkdir(parents=True, exist_ok=True)
q_path.write_text("{bad json", encoding="utf-8")
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
_question_result(q_path),
"fallback",
)
assert decision.served is False
assert decision.surface == "fallback"
def test_rejects_artifact_status_proposal_only(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
data = _valid_payload()
data["status"] = "proposal_only"
_write_artifact(q_path, data)
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback")
assert decision.served is False
assert decision.surface == "fallback"
def test_rejects_artifact_served_true(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
data = _valid_payload()
data["served"] = True
_write_artifact(q_path, data)
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback")
assert decision.served is False
def test_rejects_artifact_requires_review_false(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
data = _valid_payload()
data["requires_review"] = False
_write_artifact(q_path, data)
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback")
assert decision.served is False
def test_rejects_artifact_non_null_answer_binding(tmp_path: Path) -> None:
q_path = tmp_path / "questions" / "q.json"
data = _valid_payload()
data["answer_binding"] = {"slot": "total_count", "value": 12}
_write_artifact(q_path, data)
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback")
assert decision.served is False
def test_rejects_missing_or_blank_question_text(tmp_path: Path) -> None:
for value in (None, ""):
q_path = tmp_path / f"questions_{value!r}" / "q.json"
data = _valid_payload()
if value is None:
del data["question"]["text"]
else:
data["question"]["text"] = " "
_write_artifact(q_path, data)
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback")
assert decision.served is False
assert decision.surface == "fallback"
def test_rejects_missing_or_blank_slot_name(tmp_path: Path) -> None:
for value in (None, ""):
q_path = tmp_path / f"questions_slot_{value!r}" / "q.json"
data = _valid_payload()
if value is None:
del data["question"]["slot_name"]
else:
data["question"]["slot_name"] = " "
_write_artifact(q_path, data)
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback")
assert decision.served is False
assert decision.surface == "fallback"
def test_adapter_does_not_import_question_renderer_or_construct_prose() -> None:
path = Path(__file__).resolve().parents[1] / "core" / "epistemic_disclosure" / "ask_serving.py"
source = path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
assert node.module != "core.epistemic_questions.render"
assert not node.module.startswith("core.epistemic_questions.render.")
if isinstance(node, ast.Import):
for alias in node.names:
assert alias.name != "core.epistemic_questions.render"
assert not alias.name.startswith("core.epistemic_questions.render.")
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
assert node.func.id != "render_question"
for forbidden_template in ("How many", "What ", "Which ", "Please provide"):
assert forbidden_template not in source