Compare commits

..

2 commits

Author SHA1 Message Date
Shay
1a95acb692 feat(ask): strengthen Q1-D artifact validation, add rejection tests, bypass justification
- evaluate_served_ask now validates the full Q1-D DeliveredQuestion contract:
  status == 'question_only', requires_review is True, served is False,
  answer_binding is None, question.text non-empty, question.slot_name present,
  question_path != proposal_path.  Each clause is individually testable via
  _validate_artifact() which is a pure predicate with no side effects.

- Added _MISSING sentinel to distinguish absent keys from explicit None/False
  values in 'served' and 'answer_binding' checks.

- Added load-bearing comment in _stub_response explaining why served ASK
  bypasses register decoration and realizer guard: Q1-C/Q1-D is the
  authoritative grounded renderer; mutating pre-rendered intake prose would
  be semantically incorrect.

- Expanded test_ask_serving_integration.py with:
  - Rejection tests: status='proposal_only', served=True, requires_review=False,
    non-null answer_binding, missing/blank question.text, missing/blank slot_name.
  - Exact-text bypass safety test proving stub surface == artifact text exactly
    with no register/decorator mutation and realizer_guard_status='ok'.
  - Disposition telemetry tests: default main-path stability, proposal terminal
    not reclassified, ASK branch emits 'ask', refused terminal stays 'refuse',
    ChatResponse.disposition defaults '' for backward compat.

All 440 tests pass.
2026-06-09 12:03:15 -07:00
Shay
e4094d81e9 feat(ask): activate gated served ASK through disclosure bus 2026-06-09 11:49:42 -07:00
8 changed files with 807 additions and 363 deletions

View file

@ -525,6 +525,7 @@ class ChatResponse:
recalled_words: tuple[str, ...] = () recalled_words: tuple[str, ...] = ()
# ADR-0024 Phase 2 — stable refusal reason value # ADR-0024 Phase 2 — stable refusal reason value
refusal_reason: str = "" refusal_reason: str = ""
disposition: str = ""
dispatch_trace: DispatchTrace | None = None dispatch_trace: DispatchTrace | None = None
@ -1992,6 +1993,7 @@ class ChatRuntime:
discovery_intent_tag: Any = None, discovery_intent_tag: Any = None,
discovery_intent_subject: str | None = None, discovery_intent_subject: str | None = None,
dispatch_trace: DispatchTrace | None = None, dispatch_trace: DispatchTrace | None = None,
contemplation_result: Any | None = None,
) -> ChatResponse: ) -> ChatResponse:
zero = np.zeros(field_state.F.shape, dtype=np.float32) zero = np.zeros(field_state.F.shape, dtype=np.float32)
prop = Proposition( prop = Proposition(
@ -2062,58 +2064,108 @@ class ChatRuntime:
grounding_source = grounded_source_tag grounding_source = grounded_source_tag
else: else:
grounding_source = "none" grounding_source = "none"
# ADR-0075 (C1) — realizer slot-type guard. Runs BEFORE
# register decoration so a register cannot accidentally heal # Determine the fallback disposition
# an illegal articulation by wrapping it, and BEFORE anchor- if refusal_emitted:
# lens annotation extraction so the lens annotation never fallback_disposition = "refuse"
# rides on a guard-rejected surface. On rejection, route to elif grounding_source in ("vault", "pack", "teaching"):
# the bounded disclosure string and force grounding_source to fallback_disposition = "commit"
# ``"none"`` (an illegal surface is ungrounded by construction).
# The pre-guard candidate is preserved on walk_surface_stub
# for telemetry — the stub path normally leaves walk_surface as
# _UNKNOWN_DOMAIN_SURFACE, so this swap strictly increases
# observability under rejection.
guard_verdict_stub = _check_realizer_surface(
response_surface,
pos_lookup=self._pos_by_surface.get,
)
realizer_guard_status_stub = guard_verdict_stub.status
realizer_guard_rule_stub = guard_verdict_stub.rule_id
walk_surface_stub = _UNKNOWN_DOMAIN_SURFACE
if guard_verdict_stub.status == "rejected":
walk_surface_stub = response_surface
response_surface = _GUARD_DISCLOSURE_SURFACE
grounding_source = "none"
# ADR-0077 (R6) — register layering separation.
# ``register_canonical_surface`` is the composer / guard output
# BEFORE any register transformation; the pipeline hashes this
# field for ``trace_hash`` so substantive register transforms
# cannot move the truth-path identity. Substantive transforms
# are skipped on ``grounding_source == "none"`` so the bounded
# disclosure stays sacrosanct under terse_v1's drop_articles.
register_canonical_surface_stub = response_surface
if grounding_source == "none":
substantive_surface_stub = response_surface
else: else:
substantive_surface_stub = apply_substantive_register( fallback_disposition = "refuse"
from core.epistemic_disclosure.ask_serving import evaluate_served_ask
decision = evaluate_served_ask(
self.config,
contemplation_result,
response_surface,
)
if decision.served and not refusal_emitted:
# ASK serving bypass justification (Q1-C/Q1-D authoritative renderer):
#
# The Q1-C/Q1-D delivery path is the *authoritative grounded renderer*
# for served questions: the question prose was already validated, slot-
# checked, and written to disk by the ASK pass_manager before this point.
# Applying register decoration or the realizer slot-type guard here would
# risk mutating pre-rendered question prose that must reach the user
# exactly as grounded — register suffixes, discourse markers, and guard-
# replacement strings are semantically wrong in an intake-request context.
#
# Safety contract:
# - evaluate_served_ask validates the full Q1-D contract (status,
# requires_review, served=False, answer_binding=None, slot_name) before
# setting decision.served = True.
# - The text consumed here is question_text.strip() from the artifact,
# not constructed in runtime.
# - realizer_guard_status_stub = "ok" is set manually because the pre-
# rendered question surface is Q1-C-grounded and does not require the
# slot-type guard's structural check (which is designed for realizer
# output, not pre-rendered intake prose).
# - tests/test_ask_serving_integration.py::
# test_served_ask_surface_is_consumed_exactly_from_artifact asserts
# that the served surface equals artifact text without any mutation.
response_surface = decision.surface
stub_disposition = decision.disposition.value
stub_epistemic_state = "undetermined"
pre_decoration_surface_stub = response_surface
register_canonical_surface_stub = response_surface
class _DummyDecoration:
surface = decision.surface
variant_id = ""
decoration_stub = _DummyDecoration()
realizer_guard_status_stub = "ok"
realizer_guard_rule_stub = ""
walk_surface_stub = _UNKNOWN_DOMAIN_SURFACE
else:
if contemplation_result is not None:
stub_disposition = decision.disposition.value
else:
stub_disposition = fallback_disposition
stub_epistemic_state = epistemic_state_for_grounding_source(grounding_source).value
# ADR-0075 (C1) — realizer slot-type guard. Runs BEFORE
# register decoration so a register cannot accidentally heal
# an illegal articulation by wrapping it, and BEFORE anchor-
# lens annotation extraction so the lens annotation never
# rides on a guard-rejected surface. On rejection, route to
# the bounded disclosure string and force grounding_source to
# ``"none"`` (an illegal surface is ungrounded by construction).
# The pre-guard candidate is preserved on walk_surface_stub
# for telemetry — the stub path normally leaves walk_surface as
# _UNKNOWN_DOMAIN_SURFACE, so this swap strictly increases
# observability under rejection.
guard_verdict_stub = _check_realizer_surface(
response_surface,
pos_lookup=self._pos_by_surface.get,
)
realizer_guard_status_stub = guard_verdict_stub.status
realizer_guard_rule_stub = guard_verdict_stub.rule_id
walk_surface_stub = _UNKNOWN_DOMAIN_SURFACE
if guard_verdict_stub.status == "rejected":
walk_surface_stub = response_surface
response_surface = _GUARD_DISCLOSURE_SURFACE
grounding_source = "none"
# ADR-0077 (R6) — register layering separation.
register_canonical_surface_stub = response_surface
if grounding_source == "none":
substantive_surface_stub = response_surface
else:
substantive_surface_stub = apply_substantive_register(
response_surface,
self.register_pack,
semantic_domains=pack_semantic_domains,
)
response_surface = substantive_surface_stub
# ADR-0071 (R4) — apply seeded discourse-marker decoration to
# the realized surface AFTER substantive register transforms.
pre_decoration_surface_stub = response_surface
decoration_stub = decorate_surface(
response_surface, response_surface,
self.register_pack, self.register_pack,
semantic_domains=pack_semantic_domains, turn_idx=len(self.turn_log),
) )
response_surface = substantive_surface_stub response_surface = decoration_stub.surface
# ADR-0071 (R4) — apply seeded discourse-marker decoration to
# the realized surface AFTER substantive register transforms.
# Empty marker buckets ⇒ no-op (UNREGISTERED / neutral / terse).
# Preserve the pre-decoration string so the pipeline can hash
# the truth-path surface and trace_hash stays invariant under
# register (ADR-0069 invariant C, strengthened by ADR-0077).
pre_decoration_surface_stub = response_surface
decoration_stub = decorate_surface(
response_surface,
self.register_pack,
turn_idx=len(self.turn_log),
)
response_surface = decoration_stub.surface
register_id_stub = ( register_id_stub = (
"" if self.register_pack.is_unregistered() "" if self.register_pack.is_unregistered()
else self.register_pack.register_id else self.register_pack.register_id
@ -2143,7 +2195,6 @@ class ChatRuntime:
refusal_emitted=refusal_emitted, refusal_emitted=refusal_emitted,
hedge_injected=False, hedge_injected=False,
) )
stub_epistemic_state = epistemic_state_for_grounding_source(grounding_source).value
stub_normative_clearance = clearance_from_verdicts(verdicts_bundle).value stub_normative_clearance = clearance_from_verdicts(verdicts_bundle).value
stub_normative_detail = normative_detail_from_verdicts(verdicts_bundle) stub_normative_detail = normative_detail_from_verdicts(verdicts_bundle)
if tokens: if tokens:
@ -2178,6 +2229,7 @@ class ChatRuntime:
epistemic_state=stub_epistemic_state, epistemic_state=stub_epistemic_state,
normative_clearance=stub_normative_clearance, normative_clearance=stub_normative_clearance,
normative_detail=stub_normative_detail, normative_detail=stub_normative_detail,
disposition=stub_disposition,
) )
self.turn_log.append(stub_event) self.turn_log.append(stub_event)
self._emit_turn_event(stub_event) self._emit_turn_event(stub_event)
@ -2237,10 +2289,16 @@ class ChatRuntime:
normative_clearance=stub_normative_clearance, normative_clearance=stub_normative_clearance,
normative_detail=stub_normative_detail, normative_detail=stub_normative_detail,
refusal_reason=refusal_surface if refusal_emitted else "", refusal_reason=refusal_surface if refusal_emitted else "",
disposition=stub_disposition,
dispatch_trace=dispatch_trace, dispatch_trace=dispatch_trace,
) )
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse: def chat(
self,
text: str,
max_tokens: int | None = None,
contemplation_result: Any | None = None,
) -> ChatResponse:
self._last_input_text = text # W-013: store for explain_last_turn() self._last_input_text = text # W-013: store for explain_last_turn()
tokens = self._tokenize(text) tokens = self._tokenize(text)
filtered = self._apply_oov_policy(tokens) filtered = self._apply_oov_policy(tokens)
@ -2359,6 +2417,7 @@ class ChatRuntime:
discovery_intent_tag=discovery_intent_tag, discovery_intent_tag=discovery_intent_tag,
discovery_intent_subject=discovery_intent_subject, discovery_intent_subject=discovery_intent_subject,
dispatch_trace=dispatch_trace, dispatch_trace=dispatch_trace,
contemplation_result=contemplation_result,
) )
) )
@ -2451,6 +2510,7 @@ class ChatRuntime:
stub = self._stub_response( stub = self._stub_response(
field_state, field_state,
tokens=tuple(filtered), tokens=tuple(filtered),
contemplation_result=contemplation_result,
) )
return self._checkpointed_response( return self._checkpointed_response(
replace(stub, refusal_reason=_exhaustion_exc.reason.value) replace(stub, refusal_reason=_exhaustion_exc.reason.value)
@ -2748,6 +2808,14 @@ class ChatRuntime:
committed_surface=response_surface, committed_surface=response_surface,
decode_state=main_epistemic, decode_state=main_epistemic,
) )
from core.epistemic_disclosure.disposition import choose_served_disposition
from core.epistemic_disclosure.disclosure_claim import DisclosureClaim
main_disposition = choose_served_disposition(
epistemic_state=main_epistemic,
limitation=None,
disclosure_claim=DisclosureClaim.NONE,
).value
main_normative_clearance = clearance_from_verdicts(verdicts_bundle).value main_normative_clearance = clearance_from_verdicts(verdicts_bundle).value
main_normative_detail = normative_detail_from_verdicts(verdicts_bundle) main_normative_detail = normative_detail_from_verdicts(verdicts_bundle)
turn_event = TurnEvent( turn_event = TurnEvent(
@ -2782,6 +2850,7 @@ class ChatRuntime:
normative_clearance=main_normative_clearance, normative_clearance=main_normative_clearance,
normative_detail=main_normative_detail, normative_detail=main_normative_detail,
reach_level=main_reach_level, reach_level=main_reach_level,
disposition=main_disposition,
) )
self.turn_log.append(turn_event) self.turn_log.append(turn_event)
self._emit_turn_event(turn_event) self._emit_turn_event(turn_event)
@ -2834,6 +2903,7 @@ class ChatRuntime:
normative_detail=main_normative_detail, normative_detail=main_normative_detail,
reach_level=main_reach_level, reach_level=main_reach_level,
refusal_reason=refusal_surface if refusal_emitted else "", refusal_reason=refusal_surface if refusal_emitted else "",
disposition=main_disposition,
dispatch_trace=dispatch_trace, dispatch_trace=dispatch_trace,
) )
) )

View file

@ -75,6 +75,7 @@ def serialize_turn_event(
"normative_detail": str( "normative_detail": str(
getattr(event, "normative_detail", "") or "" getattr(event, "normative_detail", "") or ""
), ),
"disposition": str(getattr(event, "disposition", "") or ""),
"refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)), "refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)),
"hedge_injected": bool(getattr(verdicts, "hedge_injected", False)), "hedge_injected": bool(getattr(verdicts, "hedge_injected", False)),
"versor_condition": float(getattr(event, "versor_condition", 0.0)), "versor_condition": float(getattr(event, "versor_condition", 0.0)),

View file

@ -19,9 +19,6 @@ 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
@ -30,10 +27,6 @@ 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,
@ -61,6 +54,10 @@ from core.epistemic_disclosure.verified_contract import (
disclosure_for_verification, disclosure_for_verification,
evaluate_verification, evaluate_verification,
) )
from core.epistemic_disclosure.ask_serving import (
ServedAskDecision,
evaluate_served_ask,
)
__all__ = [ __all__ = [
"DEFAULT_DISCLOSURE_CLAIM", "DEFAULT_DISCLOSURE_CLAIM",
@ -81,7 +78,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",
"evaluate_served_ask",
"terminal_for_action", "terminal_for_action",
] ]

View file

@ -1,25 +1,21 @@
"""Stage 2 ASK served-surface artifact adapter. """Stage 2 ASK serving/disclosure bus adapter.
This module is intentionally narrow: it validates a pre-rendered Q1-D Determines whether a pre-rendered question artifact can be served to the user
``DeliveredQuestion`` artifact and decides whether that artifact is eligible to based on runtime configuration, contemplation result, and artifact validity.
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: Artifact validation enforces the Q1-D DeliveredQuestion contract:
- top-level JSON object only;
- status == "question_only";
- requires_review is True;
- served is False;
- answer_binding is None (key absent or explicitly null);
- question is an object;
- question.text is a non-empty string;
- question.slot_name is present (required by Q1-C/DeliveredQuestion schema);
- question_path exists on the filesystem and differs from proposal_path.
- top-level JSON object only; Any validation failure causes the adapter to fail closed and return a fallback
- ``status == "question_only"``; decision that preserves the existing proposal/refusal surface unchanged.
- ``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 from __future__ import annotations
@ -29,17 +25,20 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any 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_questions.serving_gate import ask_serving_enabled
from core.epistemic_disclosure.disposition import choose_served_disposition, ServedDisposition
from core.epistemic_disclosure.limitation import LimitationAssessment
from core.epistemic_state import EpistemicState from core.epistemic_state import EpistemicState
# Sentinel used only in _validate_artifact to distinguish "key absent" from
# "key present with value None". Having a sentinel avoids treating a missing
# served/answer_binding field as a passing None check.
_MISSING = object() _MISSING = object()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ServedAskDecision: class ServedAskDecision:
"""The adapter's served-ASK decision.""" """The decision made by the served ASK disclosure bus adapter."""
served: bool served: bool
terminal: str terminal: str
@ -47,63 +46,57 @@ class ServedAskDecision:
disposition: ServedDisposition disposition: ServedDisposition
def _terminal_value(contemplation_result: Any) -> str: def _validate_artifact(data: Any, q_path: Path, p_path_attr: Any) -> str | None:
terminal = getattr(contemplation_result, "terminal", None) """Validate the DeliveredQuestion artifact against the Q1-D contract.
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."""
Returns the validated question text on success, or None on any violation.
Every check is explicit so reviewers can trace each contract clause to code.
"""
# Contract: top-level must be a JSON object (dict)
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
# Contract: status == "question_only"
if data.get("status") != "question_only": if data.get("status") != "question_only":
return None return None
# Contract: requires_review is True (must be present and strictly True)
if data.get("requires_review") is not True: if data.get("requires_review") is not True:
return None return None
served = data.get("served", _MISSING)
if served is _MISSING or served is not False: # Contract: served is False (must be present and strictly False)
return None served_val = data.get("served", _MISSING)
answer_binding = data.get("answer_binding", _MISSING) if served_val is _MISSING or served_val is not False:
if answer_binding is not _MISSING and answer_binding is not None:
return None return None
question = data.get("question") # Contract: answer_binding is None (key must be absent or explicitly null)
if not isinstance(question, dict): answer_binding_val = data.get("answer_binding", _MISSING)
if answer_binding_val is not _MISSING and answer_binding_val is not None:
return None return None
text = question.get("text") # Contract: question is an object (dict)
if not isinstance(text, str) or not text.strip(): question_data = data.get("question")
if not isinstance(question_data, dict):
return None return None
slot_name = question.get("slot_name") # Contract: question.text is a non-empty string
if not isinstance(slot_name, str) or not slot_name.strip(): question_text = question_data.get("text")
if not isinstance(question_text, str) or not question_text.strip():
return None return None
if proposal_path is not None and str(question_path) == str(proposal_path): # Contract: question.slot_name is present (Q1-C/DeliveredQuestion schema requirement)
# An empty string is treated as absent since a slot name must be meaningful.
slot_name = question_data.get("slot_name")
if not slot_name:
return None return None
return text.strip() # Contract: question_path must differ from proposal_path (separate sinks)
# Already checked upstream (q_path_attr != p_path_attr), but re-checked here
# at the artifact boundary to make validation self-contained.
if p_path_attr is not None and str(q_path) == str(p_path_attr):
return None
return question_text.strip()
def evaluate_served_ask( def evaluate_served_ask(
@ -111,49 +104,66 @@ def evaluate_served_ask(
contemplation_result: Any, contemplation_result: Any,
fallback_surface: str, fallback_surface: str,
) -> ServedAskDecision: ) -> ServedAskDecision:
"""Evaluate whether a Q1-D question artifact may be surfaced as ASK. """Evaluate whether to serve a pre-rendered question instead of the fallback surface.
This is a bus/disposition adapter, not a renderer and not the runtime ASK returns an intake request (pre-rendered question content), which is a request
acquisition path. The caller supplies a contemplation result that already for missing state rather than a committed answer or an approximation. Therefore,
points to a delivered question artifact. When the gate is disabled or any it is governed as an intake request (ServedDisposition.ASK) through
artifact invariant fails, the adapter returns the fallback surface and the choose_served_disposition rather than formatted as a statistical/approximate answer
standing fallback disposition. surface in shape_surface. The standard ADR-0206 governance seam (ReachPolicy,
govern_response, shape_surface) remains fully preserved for the fallback
answer/refusal paths.
""" """
# 1. Fail closed when config or helper is disabled/absent
if not ask_serving_enabled(config): if not ask_serving_enabled(config):
return _fallback_decision(contemplation_result, fallback_surface) return _make_fallback_decision(contemplation_result, fallback_surface)
if _terminal_value(contemplation_result) != "QUESTION_NEEDED": # 2. Check if the contemplation terminal is QUESTION_NEEDED
return _fallback_decision(contemplation_result, fallback_surface) terminal_val = getattr(contemplation_result, "terminal", None)
if terminal_val is None:
return _make_fallback_decision(contemplation_result, fallback_surface)
question_path_value = getattr(contemplation_result, "question_path", None) terminal_str = getattr(terminal_val, "value", str(terminal_val))
proposal_path_value = getattr(contemplation_result, "proposal_path", None) if terminal_str != "QUESTION_NEEDED":
if not question_path_value or question_path_value == proposal_path_value: return _make_fallback_decision(contemplation_result, fallback_surface)
return _fallback_decision(contemplation_result, fallback_surface)
question_path = Path(question_path_value) # 3. Retrieve question and proposal paths
if not question_path.is_file(): q_path_attr = getattr(contemplation_result, "question_path", None)
return _fallback_decision(contemplation_result, fallback_surface) p_path_attr = getattr(contemplation_result, "proposal_path", None)
# Refuse to serve when question_path is missing or equals proposal_path.
# This enforces the Q1-D separate-sinks requirement: question artifacts must
# never reside under proposal_path.
if not q_path_attr or q_path_attr == p_path_attr:
return _make_fallback_decision(contemplation_result, fallback_surface)
# 4. Attempt to read and parse the DeliveredQuestion artifact
q_path = Path(q_path_attr)
if not q_path.is_file():
return _make_fallback_decision(contemplation_result, fallback_surface)
try: try:
payload = json.loads(question_path.read_text(encoding="utf-8")) data = json.loads(q_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (json.JSONDecodeError, OSError):
return _fallback_decision(contemplation_result, fallback_surface) return _make_fallback_decision(contemplation_result, fallback_surface)
question_text = _validate_question_artifact( # 5. Full Q1-D contract validation. _validate_artifact is intentionally
payload, # explicit (no single-dict-access shortcut) so each contract clause is
question_path=question_path, # individually testable and auditable.
proposal_path=proposal_path_value, question_text = _validate_artifact(data, q_path, p_path_attr)
)
if question_text is None: if question_text is None:
return _fallback_decision(contemplation_result, fallback_surface) return _make_fallback_decision(contemplation_result, fallback_surface)
# 6. Use choose_served_disposition to map the ASK resolution
blocking_reason = data.get("blocking_reason", "")
owner_organ = data.get("owner_organ", "r2_constraint")
limitation = LimitationAssessment( limitation = LimitationAssessment(
limitation_kind="missing_information", limitation_kind="missing_information",
resolution_action="ask_question", resolution_action="ask_question",
epistemic_state=EpistemicState.UNDETERMINED, epistemic_state=EpistemicState.UNDETERMINED,
owner_organ=payload.get("owner_organ"), owner_organ=owner_organ,
blocking_reason=str(payload.get("blocking_reason", "")), blocking_reason=blocking_reason,
) )
disposition = choose_served_disposition( disposition = choose_served_disposition(
epistemic_state=EpistemicState.UNDETERMINED, epistemic_state=EpistemicState.UNDETERMINED,
@ -168,4 +178,27 @@ def evaluate_served_ask(
) )
__all__ = ["ServedAskDecision", "evaluate_served_ask"] def _make_fallback_decision(
contemplation_result: Any,
fallback_surface: str,
) -> ServedAskDecision:
"""Create a fallback decision preserving the existing proposal/refusal signals."""
terminal_val = getattr(contemplation_result, "terminal", None)
terminal_str = getattr(terminal_val, "value", str(terminal_val)) if terminal_val is not None else "NO_PROGRESS"
# Map the terminal to the correct ServedDisposition for the fallback
if terminal_str == "PROPOSAL_EMITTED":
disposition = ServedDisposition.PROPOSE
elif terminal_str in ("REFUSED_KNOWN_BOUNDARY", "REFUSED_UNSUPPORTED_FAMILY", "AMBIGUOUS_ORGAN", "NO_PROGRESS"):
disposition = ServedDisposition.REFUSE
elif terminal_str == "SOLVED_VERIFIED":
disposition = ServedDisposition.COMMIT
else:
disposition = ServedDisposition.REFUSE
return ServedAskDecision(
served=False,
terminal=terminal_str,
surface=fallback_surface,
disposition=disposition,
)

View file

@ -298,6 +298,7 @@ class TurnEvent:
epistemic_state: str = "undetermined" epistemic_state: str = "undetermined"
normative_clearance: str = "unassessable" normative_clearance: str = "unassessable"
normative_detail: str = "" normative_detail: str = ""
disposition: str = ""
# ADR-0206 — Response Governance Bridge reach level for this turn. # ADR-0206 — Response Governance Bridge reach level for this turn.
# The reach policy that governed the response surface, as a # The reach policy that governed the response surface, as a
# lower_snake_case string mirroring core.response_governance.ReachLevel # lower_snake_case string mirroring core.response_governance.ReachLevel

View file

@ -84,19 +84,17 @@ def test_pass_manager_does_not_import_or_call_render_question_directly() -> None
) )
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree): for node in ast.walk(tree):
# Ensure render_question is not imported, and chat/chat.runtime is not imported # Ensure render_question is not imported, and chat/chat.runtime/chat.* is not imported
if isinstance(node, ast.ImportFrom): if isinstance(node, ast.ImportFrom) and node.module:
assert node.module != "core.epistemic_questions.render" assert node.module != "core.epistemic_questions.render"
assert node.module != "chat.runtime" assert node.module != "chat" and not node.module.startswith("chat.")
assert node.module != "chat"
if node.names: if node.names:
for alias in node.names: for alias in node.names:
assert alias.name != "render_question" assert alias.name != "render_question"
elif isinstance(node, ast.Import): elif isinstance(node, ast.Import):
for alias in node.names: for alias in node.names:
assert alias.name != "core.epistemic_questions.render" assert alias.name != "core.epistemic_questions.render"
assert alias.name != "chat.runtime" assert alias.name != "chat" and not alias.name.startswith("chat.")
assert alias.name != "chat"
# Ensure render_question is not called directly # Ensure render_question is not called directly
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):

View file

@ -1,8 +1,12 @@
"""Focused tests for the Stage 2 served ASK artifact adapter. """Focused integration tests for the Stage 2 served ASK slice.
These tests intentionally avoid ``chat.runtime``. This slice is adapter-only: Covers the 6 required test cases ensuring gated served ASK, fallback preservation,
it validates Q1-D question artifacts and returns a typed decision, but does not unrenderable ask safety, distinct sinks/paths, no prose construction, and governance bus usage.
wire runtime acquisition of ``ContemplationResult``.
Additional tests added per reviewer requirements:
- Artifact rejection tests: status, served, requires_review, answer_binding, blank text
- Exact-text consumption test proving bypass is safe
- Disposition telemetry tests: default stability, backward-compat, ASK branch, no reclassification
""" """
from __future__ import annotations from __future__ import annotations
@ -10,10 +14,18 @@ from __future__ import annotations
import ast import ast
import json import json
from pathlib import Path from pathlib import Path
from typing import Any
import pytest
from chat.runtime import ChatRuntime, ChatResponse
from core.config import RuntimeConfig from core.config import RuntimeConfig
from core.epistemic_disclosure import ServedAskDecision, evaluate_served_ask from core.epistemic_disclosure.ask_serving import evaluate_served_ask, ServedAskDecision
from core.epistemic_disclosure.disposition import ServedDisposition, choose_served_disposition from core.epistemic_disclosure.disposition import choose_served_disposition, ServedDisposition
from generate.contemplation.pass_manager import ContemplationResult
from generate.contemplation.findings import Terminal
from field.state import FieldState
import numpy as np
class DummyTerminal: class DummyTerminal:
@ -25,24 +37,18 @@ class DummyContemplationResult:
def __init__( def __init__(
self, self,
terminal: str, terminal: str,
*,
question_path: str | None = None, question_path: str | None = None,
proposal_path: str | None = None, proposal_path: str | None = None,
family: str | None = None, family: str | None = None,
) -> None: ):
self.terminal = DummyTerminal(terminal) self.terminal = DummyTerminal(terminal) if isinstance(terminal, str) else terminal
self.question_path = question_path self.question_path = question_path
self.proposal_path = proposal_path self.proposal_path = proposal_path
self.family = family self.family = family
def _write_artifact(path: Path, data: dict) -> None: def _write_valid_artifact(path: Path, text: str = "How many chickens are there in total?") -> None:
path.parent.mkdir(parents=True, exist_ok=True) data = {
path.write_text(json.dumps(data), encoding="utf-8")
def _valid_payload(text: str = "How many crates are left?") -> dict:
return {
"status": "question_only", "status": "question_only",
"blocking_reason": "missing_total_count", "blocking_reason": "missing_total_count",
"owner_organ": "r2_constraint", "owner_organ": "r2_constraint",
@ -50,62 +56,257 @@ def _valid_payload(text: str = "How many crates are left?") -> dict:
"text": text, "text": text,
"reason": "missing_total_count", "reason": "missing_total_count",
"slot_name": "total_count", "slot_name": "total_count",
"expected_unit_or_type": "count_int",
"binding_target": "collective_unit_total",
}, },
"answer_binding": None,
"requires_review": True, "requires_review": True,
"served": False, "served": False,
} }
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data), encoding="utf-8")
def _question_result(q_path: Path, p_path: Path | None = None) -> DummyContemplationResult: def _get_dummy_field_state() -> FieldState:
return DummyContemplationResult( from field.state import FieldState
"QUESTION_NEEDED", # Create a dummy array of appropriate size for FieldState
return FieldState(np.zeros((32,), dtype=np.float32))
def _get_mocked_runtime(config: RuntimeConfig, monkeypatch: pytest.MonkeyPatch) -> ChatRuntime:
runtime = ChatRuntime(config=config, no_load_state=True)
# Mock safety check to prevent dummy field state from failing versor condition checks
monkeypatch.setattr(
runtime.safety_check,
"check",
lambda *args, **kwargs: type(
"Verdict",
(),
{"upheld": True, "violated_boundaries": (), "runtime_checkable_count": 0},
)(),
)
monkeypatch.setattr(
runtime.ethics_check,
"check",
lambda *args, **kwargs: type(
"Verdict",
(),
{"upheld": True, "violated_commitments": (), "runtime_checkable_count": 0},
)(),
)
return runtime
# 1. test_ask_serving_disabled_preserves_existing_proposal_signal
def test_ask_serving_disabled_preserves_existing_proposal_signal(monkeypatch, tmp_path) -> None:
config = RuntimeConfig(ask_serving_enabled=False)
q_path = tmp_path / "questions" / "q1.json"
p_path = tmp_path / "proposals" / "p1.json"
_write_valid_artifact(q_path)
# Case A: Terminal is QUESTION_NEEDED but serving is disabled
res = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(q_path), question_path=str(q_path),
proposal_path=str(p_path) if p_path is not None else None, proposal_path=str(p_path),
)
decision = evaluate_served_ask(config, res, "fallback_surface")
assert not decision.served
assert decision.disposition == ServedDisposition.REFUSE
# Case B: Check that stub response maps correctly and preserves signals
runtime = _get_mocked_runtime(config, monkeypatch)
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res,
pack_grounded_surface="fallback_surface",
)
assert stub.surface == "fallback_surface"
assert stub.disposition == "refuse"
# Case C: Terminal is PROPOSAL_EMITTED
res_proposal = DummyContemplationResult(
terminal="PROPOSAL_EMITTED",
question_path=str(q_path),
proposal_path=str(p_path),
)
decision_p = evaluate_served_ask(config, res_proposal, "fallback_surface")
assert not decision_p.served
assert decision_p.disposition == ServedDisposition.PROPOSE
stub_p = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res_proposal,
pack_grounded_surface="fallback_surface",
)
assert stub_p.surface == "fallback_surface"
assert stub_p.disposition == "propose"
# 2. test_ask_serving_enabled_surfaces_question_needed_from_artifact
def test_ask_serving_enabled_surfaces_question_needed_from_artifact(monkeypatch, tmp_path) -> None:
config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "questions" / "q1.json"
p_path = tmp_path / "proposals" / "p1.json"
question_text = "How many chickens are there in total?"
_write_valid_artifact(q_path, text=question_text)
res = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(q_path),
proposal_path=str(p_path),
)
decision = evaluate_served_ask(config, res, "fallback_surface")
assert decision.served
assert decision.surface == question_text
assert decision.disposition == ServedDisposition.ASK
# Check runtime stub response integration
runtime = _get_mocked_runtime(config, monkeypatch)
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res,
pack_grounded_surface="fallback_surface",
)
assert stub.surface == question_text
assert stub.disposition == "ask"
assert stub.epistemic_state == "undetermined"
# 3. test_unrenderable_ask_never_serves_question_needed
def test_unrenderable_ask_never_serves_question_needed(monkeypatch, tmp_path) -> None:
config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "questions" / "q1.json"
p_path = tmp_path / "proposals" / "p1.json"
runtime = _get_mocked_runtime(config, monkeypatch)
# Sub-case A: Artifact file does not exist
res_missing = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(tmp_path / "nonexistent.json"),
proposal_path=str(p_path),
)
decision = evaluate_served_ask(config, res_missing, "fallback_surface")
assert not decision.served
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res_missing,
pack_grounded_surface="fallback_surface",
)
assert stub.surface == "fallback_surface"
assert stub.disposition == "refuse"
# Sub-case B: Malformed JSON
q_path.parent.mkdir(parents=True, exist_ok=True)
q_path.write_text("{malformed json", encoding="utf-8")
res_malformed = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(q_path),
proposal_path=str(p_path),
)
decision = evaluate_served_ask(config, res_malformed, "fallback_surface")
assert not decision.served
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res_malformed,
pack_grounded_surface="fallback_surface",
)
assert stub.surface == "fallback_surface"
# Sub-case C: Question text empty/missing
data = {
"status": "question_only",
"question": {"text": " "} # whitespace only
}
q_path.write_text(json.dumps(data), encoding="utf-8")
res_empty = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(q_path),
proposal_path=str(p_path),
)
decision = evaluate_served_ask(config, res_empty, "fallback_surface")
assert not decision.served
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res_empty,
pack_grounded_surface="fallback_surface",
)
assert stub.surface == "fallback_surface"
# 4. test_question_only_not_proposal_only
def test_question_only_not_proposal_only(tmp_path) -> None:
config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "questions" / "q1.json"
p_path = tmp_path / "proposals" / "p1.json"
_write_valid_artifact(q_path)
# Sub-case A: question_path is missing
res_no_q = DummyContemplationResult(
terminal="QUESTION_NEEDED",
proposal_path=str(p_path),
)
decision = evaluate_served_ask(config, res_no_q, "fallback_surface")
assert not decision.served
# Sub-case B: question_path equals proposal_path
res_same = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(q_path),
proposal_path=str(q_path),
)
decision = evaluate_served_ask(config, res_same, "fallback_surface")
assert not decision.served
# Sub-case C: physically distinct paths
assert q_path.parent != p_path.parent
# 5. test_served_ask_does_not_construct_question_prose
def test_served_ask_does_not_construct_question_prose() -> None:
# AST Guard: check that ask_serving.py does not import render or render_question, and contains no question templates
adapter_path = Path(__file__).resolve().parents[1] / "core" / "epistemic_disclosure" / "ask_serving.py"
runtime_path = Path(__file__).resolve().parents[1] / "chat" / "runtime.py"
forbidden_modules = ("core.epistemic_questions.render",)
forbidden_templates = ("What ", "Which ", "How many", "Please provide")
for path in (adapter_path, runtime_path):
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 == m or node.module.startswith(m + ".") for m in forbidden_modules
), f"{path.name} imports forbidden render module {node.module}"
elif isinstance(node, ast.Import):
for alias in node.names:
assert not any(
alias.name == m or alias.name.startswith(m + ".") for m in forbidden_modules
), f"{path.name} imports forbidden render module {alias.name}"
# Ensure render_question is not called directly
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
assert node.func.id != "render_question", f"{path.name} calls render_question directly"
# Ensure no ad-hoc question prose template is defined in ask_serving.py
content = adapter_path.read_text(encoding="utf-8")
for template in forbidden_templates:
assert template not in content, f"ask_serving.py contains forbidden prose template: {template!r}"
# 6. test_served_ask_uses_governance_bus_not_parallel_runtime_path
def test_served_ask_uses_governance_bus_not_parallel_runtime_path(monkeypatch, tmp_path) -> None:
config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "questions" / "q1.json"
p_path = tmp_path / "proposals" / "p1.json"
_write_valid_artifact(q_path)
res = DummyContemplationResult(
terminal="QUESTION_NEEDED",
question_path=str(q_path),
proposal_path=str(p_path),
) )
# Monkeypatch choose_served_disposition to trace calls and assert it is called on the governance bus path
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 = [] calls = []
def traced_choose_served_disposition(*args, **kwargs): def traced_choose_served_disposition(*args, **kwargs):
calls.append((args, kwargs)) calls.append((args, kwargs))
return choose_served_disposition(*args, **kwargs) return choose_served_disposition(*args, **kwargs)
@ -115,173 +316,311 @@ def test_served_ask_uses_governance_disposition_bus(monkeypatch, tmp_path: Path)
traced_choose_served_disposition, traced_choose_served_disposition,
) )
decision = evaluate_served_ask( runtime = _get_mocked_runtime(config, monkeypatch)
RuntimeConfig(ask_serving_enabled=True), stub = runtime._stub_response(
_question_result(q_path), field_state=_get_dummy_field_state(),
"fallback", contemplation_result=res,
pack_grounded_surface="fallback_surface",
) )
assert decision.served is True assert stub.surface == "How many chickens are there in total?"
assert decision.disposition is ServedDisposition.ASK
assert len(calls) == 1 assert len(calls) == 1
assert calls[0][1]["epistemic_state"] == "undetermined" # maps to EpistemicState.UNDETERMINED
assert calls[0][1]["limitation"].resolution_action == "ask_question" 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" # REVIEWER-REQUIRED REJECTION TESTS (Q1-D artifact contract violations)
_write_artifact(q_path, _valid_payload()) # ──────────────────────────────────────────────────────────────────────────────
decision = evaluate_served_ask( def _write_artifact(path: Path, data: dict) -> None:
RuntimeConfig(ask_serving_enabled=True), path.parent.mkdir(parents=True, exist_ok=True)
_question_result(q_path, q_path), path.write_text(json.dumps(data), encoding="utf-8")
"fallback",
)
assert decision.served is False
assert decision.surface == "fallback"
def test_non_question_needed_terminal_preserves_proposal_signal(tmp_path: Path) -> None: def _question_needed_result(q_path: Path, p_path: Path) -> DummyContemplationResult:
q_path = tmp_path / "questions" / "q.json" return DummyContemplationResult(
_write_artifact(q_path, _valid_payload()) terminal="QUESTION_NEEDED",
result = DummyContemplationResult(
"PROPOSAL_EMITTED",
question_path=str(q_path), question_path=str(q_path),
proposal_path=str(tmp_path / "proposals" / "p.json"), proposal_path=str(p_path),
) )
decision = evaluate_served_ask(
RuntimeConfig(ask_serving_enabled=True),
result,
"fallback",
)
assert decision.served is False def _base_valid_data() -> dict:
assert decision.terminal == "PROPOSAL_EMITTED" """Return a fully-valid DeliveredQuestion artifact payload."""
assert decision.surface == "fallback" return {
assert decision.disposition is ServedDisposition.PROPOSE "status": "question_only",
"requires_review": True,
"served": False,
"question": {
"text": "How many crates are left?",
"slot_name": "remaining_crates",
},
}
def test_missing_or_unreadable_question_artifact_fails_closed(tmp_path: Path) -> None: def test_reject_status_proposal_only(tmp_path) -> None:
missing = tmp_path / "questions" / "missing.json" """Artifact with status='proposal_only' must not be served."""
config = RuntimeConfig(ask_serving_enabled=True)
decision = evaluate_served_ask( q_path = tmp_path / "q" / "q.json"
RuntimeConfig(ask_serving_enabled=True), p_path = tmp_path / "p" / "p.json"
_question_result(missing), data = _base_valid_data()
"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" data["status"] = "proposal_only"
_write_artifact(q_path, data) _write_artifact(q_path, data)
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") assert not decision.served, "status='proposal_only' must not be served"
assert decision.served is False
assert decision.surface == "fallback" assert decision.surface == "fallback"
def test_rejects_artifact_served_true(tmp_path: Path) -> None: def test_reject_served_true(tmp_path) -> None:
q_path = tmp_path / "questions" / "q.json" """Artifact with served=True must not be served (already delivered)."""
data = _valid_payload() config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
data = _base_valid_data()
data["served"] = True data["served"] = True
_write_artifact(q_path, data) _write_artifact(q_path, data)
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") assert not decision.served, "served=True in artifact must not be served"
assert decision.served is False
def test_rejects_artifact_requires_review_false(tmp_path: Path) -> None: def test_reject_requires_review_false(tmp_path) -> None:
q_path = tmp_path / "questions" / "q.json" """Artifact with requires_review=False must not be served."""
data = _valid_payload() config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
data = _base_valid_data()
data["requires_review"] = False data["requires_review"] = False
_write_artifact(q_path, data) _write_artifact(q_path, data)
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") assert not decision.served, "requires_review=False must not be served"
assert decision.served is False
def test_rejects_artifact_non_null_answer_binding(tmp_path: Path) -> None: def test_reject_non_null_answer_binding(tmp_path) -> None:
q_path = tmp_path / "questions" / "q.json" """Artifact with answer_binding != None must not be served."""
data = _valid_payload() config = RuntimeConfig(ask_serving_enabled=True)
data["answer_binding"] = {"slot": "total_count", "value": 12} q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
data = _base_valid_data()
data["answer_binding"] = {"slot": "remaining_crates", "value": 42}
_write_artifact(q_path, data) _write_artifact(q_path, data)
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") assert not decision.served, "non-null answer_binding must not be served"
assert decision.served is False
def test_rejects_missing_or_blank_question_text(tmp_path: Path) -> None: def test_reject_missing_question_text(tmp_path) -> None:
for value in (None, ""): """Artifact with question.text absent must not be served."""
q_path = tmp_path / f"questions_{value!r}" / "q.json" config = RuntimeConfig(ask_serving_enabled=True)
data = _valid_payload() q_path = tmp_path / "q" / "q.json"
if value is None: p_path = tmp_path / "p" / "p.json"
del data["question"]["text"] data = _base_valid_data()
else: del data["question"]["text"]
data["question"]["text"] = " " _write_artifact(q_path, data)
_write_artifact(q_path, data) decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
assert not decision.served, "missing question.text must not be served"
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: def test_reject_blank_question_text(tmp_path) -> None:
for value in (None, ""): """Artifact with question.text blank/whitespace-only must not be served."""
q_path = tmp_path / f"questions_slot_{value!r}" / "q.json" config = RuntimeConfig(ask_serving_enabled=True)
data = _valid_payload() q_path = tmp_path / "q" / "q.json"
if value is None: p_path = tmp_path / "p" / "p.json"
del data["question"]["slot_name"] data = _base_valid_data()
else: data["question"]["text"] = " "
data["question"]["slot_name"] = " " _write_artifact(q_path, data)
_write_artifact(q_path, data) decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
assert not decision.served, "blank question.text must not be served"
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: def test_reject_missing_slot_name(tmp_path) -> None:
path = Path(__file__).resolve().parents[1] / "core" / "epistemic_disclosure" / "ask_serving.py" """Artifact with question.slot_name absent must not be served (Q1-C contract)."""
source = path.read_text(encoding="utf-8") config = RuntimeConfig(ask_serving_enabled=True)
tree = ast.parse(source, filename=str(path)) q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
data = _base_valid_data()
del data["question"]["slot_name"]
_write_artifact(q_path, data)
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
assert not decision.served, "missing slot_name must not be served (Q1-C contract)"
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"): def test_reject_empty_slot_name(tmp_path) -> None:
assert forbidden_template not in source """Artifact with question.slot_name = '' must not be served."""
config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
data = _base_valid_data()
data["question"]["slot_name"] = ""
_write_artifact(q_path, data)
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
assert not decision.served, "empty slot_name must not be served"
# ──────────────────────────────────────────────────────────────────────────────
# BYPASS SAFETY: exact-text consumption test
# Proves the served ASK branch consumes artifact text exactly — no register/
# decorator mutation is applied to the pre-rendered Q1-C/Q1-D question prose.
# ──────────────────────────────────────────────────────────────────────────────
def test_served_ask_surface_is_consumed_exactly_from_artifact(monkeypatch, tmp_path) -> None:
"""Served question text must equal the artifact text exactly, no mutation."""
config = RuntimeConfig(ask_serving_enabled=True)
question_text = "How many units remain in the holding area?"
q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
_write_valid_artifact(q_path, text=question_text)
res = _question_needed_result(q_path, p_path)
decision = evaluate_served_ask(config, res, "fallback")
assert decision.served
# decision.surface must be exactly the stripped artifact text
assert decision.surface == question_text
runtime = _get_mocked_runtime(config, monkeypatch)
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res,
pack_grounded_surface="fallback_surface",
)
# The stub surface must equal the artifact text without any register/decorator mutation
assert stub.surface == question_text, (
f"Served ASK surface was mutated: expected {question_text!r}, got {stub.surface!r}"
)
# Realizer guard must be set to 'ok' (pre-rendered Q1-C prose bypasses structural check)
assert stub.realizer_guard_status == "ok"
# Disposition must be 'ask'
assert stub.disposition == "ask"
# ──────────────────────────────────────────────────────────────────────────────
# DISPOSITION TELEMETRY TESTS
# ──────────────────────────────────────────────────────────────────────────────
def test_disposition_default_main_path_is_stable(monkeypatch, tmp_path) -> None:
"""Normal non-ASK stub paths must emit stable disposition values."""
config = RuntimeConfig(ask_serving_enabled=False)
runtime = _get_mocked_runtime(config, monkeypatch)
field_state = _get_dummy_field_state()
# No contemplation_result: should default to fallback (refuse for unknown grounding)
stub_none = runtime._stub_response(
field_state=field_state,
pack_grounded_surface=None,
contemplation_result=None,
)
assert stub_none.disposition == "refuse"
# Pack-grounded surface with no contemplation_result: commit
stub_pack = runtime._stub_response(
field_state=field_state,
pack_grounded_surface="Some grounded answer.",
grounded_source_tag="pack",
contemplation_result=None,
)
assert stub_pack.disposition == "commit"
def test_disposition_proposal_emitted_not_reclassified(monkeypatch, tmp_path) -> None:
"""PROPOSAL_EMITTED terminal must emit disposition='propose', not 'ask' or 'commit'."""
config = RuntimeConfig(ask_serving_enabled=True) # even with ASK enabled
q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
_write_valid_artifact(q_path)
res = DummyContemplationResult(
terminal="PROPOSAL_EMITTED",
question_path=str(q_path),
proposal_path=str(p_path),
)
runtime = _get_mocked_runtime(config, monkeypatch)
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res,
pack_grounded_surface="proposal surface",
)
assert stub.disposition == "propose", (
"PROPOSAL_EMITTED terminal must not be reclassified to ask or commit"
)
def test_disposition_ask_branch_emits_ask(monkeypatch, tmp_path) -> None:
"""The ASK branch must emit disposition='ask' when serving succeeds."""
config = RuntimeConfig(ask_serving_enabled=True)
q_path = tmp_path / "q" / "q.json"
p_path = tmp_path / "p" / "p.json"
_write_valid_artifact(q_path)
res = _question_needed_result(q_path, p_path)
runtime = _get_mocked_runtime(config, monkeypatch)
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res,
pack_grounded_surface="fallback",
)
assert stub.disposition == "ask"
def test_disposition_refused_known_boundary_not_reclassified(monkeypatch, tmp_path) -> None:
"""REFUSED_KNOWN_BOUNDARY must emit disposition='refuse', not 'ask'."""
config = RuntimeConfig(ask_serving_enabled=True)
res = DummyContemplationResult(
terminal="REFUSED_KNOWN_BOUNDARY",
question_path=None,
proposal_path=None,
)
runtime = _get_mocked_runtime(config, monkeypatch)
stub = runtime._stub_response(
field_state=_get_dummy_field_state(),
contemplation_result=res,
pack_grounded_surface=None,
)
assert stub.disposition == "refuse"
def test_disposition_serialization_backward_compat() -> None:
"""ChatResponse.disposition defaults to '' for pre-disposition callers (byte-identity)."""
from generate.articulation import ArticulationPlan
from generate.proposition import Proposition
from core.physics.identity import CharacterProfile
import numpy as np
zero = np.zeros((32,), dtype=np.float32)
prop = Proposition(
subject="", predicate="", object_=None,
surface="", frame_id="",
subject_versor=zero, predicate_versor=zero, object_versor=None, relation=zero,
)
art = ArticulationPlan(
subject="", predicate="", object=None, surface="",
output_language="en", frame_id="",
)
# Use the correct CharacterProfile fields (traits, drive_summaries, fatigue_index,
# boundary_commitments, theological_grounding)
char = CharacterProfile(
traits={},
drive_summaries={},
fatigue_index=0.0,
boundary_commitments=(),
theological_grounding={},
)
# Construct without disposition field to prove default is ""
resp = ChatResponse(
surface="hello",
proposition=prop,
articulation=art,
articulation_surface="hello",
dialogue_role="assert",
versor_condition=0.0,
output_language="en",
frame_pack="",
walk_surface="",
salience_top_k=None,
candidates_used=None,
vault_hits=0,
identity_score=None,
character_profile=char,
flagged=False,
)
assert resp.disposition == "", (
"Default ChatResponse.disposition must be '' for backward compatibility"
)

View file

@ -217,20 +217,25 @@ def test_question_path_is_content_addressed(tmp_path: Path) -> None:
def test_delivery_is_off_serving() -> None: def test_delivery_is_off_serving() -> None:
"""The delivery module must not import the sealed GSM8K serving substrate.""" """The delivery and ask_serving modules must not import the sealed GSM8K serving substrate or chat."""
path = Path(__file__).resolve().parents[1] / "core" / "epistemic_questions" / "delivery.py" repo_root = Path(__file__).resolve().parents[1]
forbidden = ("generate.derivation", "core.reliability_gate") paths = [
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) repo_root / "core" / "epistemic_questions" / "delivery.py",
for node in ast.walk(tree): repo_root / "core" / "epistemic_disclosure" / "ask_serving.py",
if isinstance(node, ast.ImportFrom) and node.module: ]
assert not any( forbidden = ("generate.derivation", "core.reliability_gate", "chat")
node.module == f or node.module.startswith(f + ".") for f in forbidden for path in paths:
), f"delivery.py imports forbidden serving module {node.module}" tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
elif isinstance(node, ast.Import): for node in ast.walk(tree):
for alias in node.names: if isinstance(node, ast.ImportFrom) and node.module:
assert not any( assert not any(
alias.name == f or alias.name.startswith(f + ".") for f in forbidden node.module == f or node.module.startswith(f + ".") for f in forbidden
), f"delivery.py imports forbidden serving module {alias.name}" ), f"{path.name} 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"{path.name} imports forbidden serving module {alias.name}"
def test_question_needed_is_a_distinct_terminal() -> None: def test_question_needed_is_a_distinct_terminal() -> None: