diff --git a/chat/runtime.py b/chat/runtime.py index fe4692c1..7f658e04 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -525,6 +525,7 @@ class ChatResponse: recalled_words: tuple[str, ...] = () # ADR-0024 Phase 2 — stable refusal reason value refusal_reason: str = "" + disposition: str = "" dispatch_trace: DispatchTrace | None = None @@ -1992,6 +1993,7 @@ class ChatRuntime: discovery_intent_tag: Any = None, discovery_intent_subject: str | None = None, dispatch_trace: DispatchTrace | None = None, + contemplation_result: Any | None = None, ) -> ChatResponse: zero = np.zeros(field_state.F.shape, dtype=np.float32) prop = Proposition( @@ -2062,58 +2064,85 @@ class ChatRuntime: grounding_source = grounded_source_tag else: grounding_source = "none" - # 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`` 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 + + # Determine the fallback disposition + if refusal_emitted: + fallback_disposition = "refuse" + elif grounding_source in ("vault", "pack", "teaching"): + fallback_disposition = "commit" 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: + 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, self.register_pack, - semantic_domains=pack_semantic_domains, + turn_idx=len(self.turn_log), ) - response_surface = substantive_surface_stub - # 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 + response_surface = decoration_stub.surface + register_id_stub = ( "" if self.register_pack.is_unregistered() else self.register_pack.register_id @@ -2143,7 +2172,6 @@ class ChatRuntime: refusal_emitted=refusal_emitted, 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_detail = normative_detail_from_verdicts(verdicts_bundle) if tokens: @@ -2178,6 +2206,7 @@ class ChatRuntime: epistemic_state=stub_epistemic_state, normative_clearance=stub_normative_clearance, normative_detail=stub_normative_detail, + disposition=stub_disposition, ) self.turn_log.append(stub_event) self._emit_turn_event(stub_event) @@ -2237,10 +2266,16 @@ class ChatRuntime: normative_clearance=stub_normative_clearance, normative_detail=stub_normative_detail, refusal_reason=refusal_surface if refusal_emitted else "", + disposition=stub_disposition, 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() tokens = self._tokenize(text) filtered = self._apply_oov_policy(tokens) @@ -2359,6 +2394,7 @@ class ChatRuntime: discovery_intent_tag=discovery_intent_tag, discovery_intent_subject=discovery_intent_subject, dispatch_trace=dispatch_trace, + contemplation_result=contemplation_result, ) ) @@ -2451,6 +2487,7 @@ class ChatRuntime: stub = self._stub_response( field_state, tokens=tuple(filtered), + contemplation_result=contemplation_result, ) return self._checkpointed_response( replace(stub, refusal_reason=_exhaustion_exc.reason.value) @@ -2748,6 +2785,14 @@ class ChatRuntime: committed_surface=response_surface, 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_detail = normative_detail_from_verdicts(verdicts_bundle) turn_event = TurnEvent( @@ -2782,6 +2827,7 @@ class ChatRuntime: normative_clearance=main_normative_clearance, normative_detail=main_normative_detail, reach_level=main_reach_level, + disposition=main_disposition, ) self.turn_log.append(turn_event) self._emit_turn_event(turn_event) @@ -2834,6 +2880,7 @@ class ChatRuntime: normative_detail=main_normative_detail, reach_level=main_reach_level, refusal_reason=refusal_surface if refusal_emitted else "", + disposition=main_disposition, dispatch_trace=dispatch_trace, ) ) diff --git a/chat/telemetry.py b/chat/telemetry.py index 815bbb8c..0c44a2fc 100644 --- a/chat/telemetry.py +++ b/chat/telemetry.py @@ -75,6 +75,7 @@ def serialize_turn_event( "normative_detail": str( getattr(event, "normative_detail", "") or "" ), + "disposition": str(getattr(event, "disposition", "") or ""), "refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)), "hedge_injected": bool(getattr(verdicts, "hedge_injected", False)), "versor_condition": float(getattr(event, "versor_condition", 0.0)), diff --git a/core/epistemic_disclosure/__init__.py b/core/epistemic_disclosure/__init__.py index 57bfc46a..0da8f938 100644 --- a/core/epistemic_disclosure/__init__.py +++ b/core/epistemic_disclosure/__init__.py @@ -54,6 +54,10 @@ from core.epistemic_disclosure.verified_contract import ( disclosure_for_verification, evaluate_verification, ) +from core.epistemic_disclosure.ask_serving import ( + ServedAskDecision, + evaluate_served_ask, +) __all__ = [ "DEFAULT_DISCLOSURE_CLAIM", @@ -64,6 +68,7 @@ __all__ = [ "LimitationKind", "MissingSlot", "ResolutionAction", + "ServedAskDecision", "ServedDisposition", "VerificationObligation", "VerificationProof", @@ -74,5 +79,6 @@ __all__ = [ "choose_served_disposition", "disclosure_for_verification", "evaluate_verification", + "evaluate_served_ask", "terminal_for_action", ] diff --git a/core/epistemic_disclosure/ask_serving.py b/core/epistemic_disclosure/ask_serving.py new file mode 100644 index 00000000..7f8cde4d --- /dev/null +++ b/core/epistemic_disclosure/ask_serving.py @@ -0,0 +1,135 @@ +"""Stage 2 ASK serving/disclosure bus adapter. + +Determines whether a pre-rendered question artifact can be served to the user +based on runtime configuration, contemplation result, and artifact validity. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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 + + +@dataclass(frozen=True, slots=True) +class ServedAskDecision: + """The decision made by the served ASK disclosure bus adapter.""" + + served: bool + terminal: str + surface: str + disposition: ServedDisposition + + +def evaluate_served_ask( + config: Any, + contemplation_result: Any, + fallback_surface: str, +) -> ServedAskDecision: + """Evaluate whether to serve a pre-rendered question instead of the fallback surface. + + ASK returns an intake request (pre-rendered question content), which is a request + for missing state rather than a committed answer or an approximation. Therefore, + it is governed as an intake request (ServedDisposition.ASK) through + choose_served_disposition rather than formatted as a statistical/approximate answer + 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): + return _make_fallback_decision(contemplation_result, fallback_surface) + + # 2. Check if the contemplation terminal is QUESTION_NEEDED + terminal_val = getattr(contemplation_result, "terminal", None) + if terminal_val is None: + return _make_fallback_decision(contemplation_result, fallback_surface) + + terminal_str = getattr(terminal_val, "value", str(terminal_val)) + if terminal_str != "QUESTION_NEEDED": + return _make_fallback_decision(contemplation_result, fallback_surface) + + # 3. Retrieve question and proposal paths + q_path_attr = getattr(contemplation_result, "question_path", None) + p_path_attr = getattr(contemplation_result, "proposal_path", None) + + # Refuse to serve when question_path is missing or equals 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: + data = json.loads(q_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return _make_fallback_decision(contemplation_result, fallback_surface) + + # Refuse to serve if the artifact is malformed or missing key fields + if not isinstance(data, dict): + return _make_fallback_decision(contemplation_result, fallback_surface) + + question_data = data.get("question") + if not isinstance(question_data, dict): + return _make_fallback_decision(contemplation_result, fallback_surface) + + question_text = question_data.get("text") + if not question_text or not isinstance(question_text, str) or not question_text.strip(): + return _make_fallback_decision(contemplation_result, fallback_surface) + + # 5. 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_kind="missing_information", + resolution_action="ask_question", + epistemic_state=EpistemicState.UNDETERMINED, + owner_organ=owner_organ, + blocking_reason=blocking_reason, + ) + disposition = choose_served_disposition( + epistemic_state=EpistemicState.UNDETERMINED, + limitation=limitation, + ) + + return ServedAskDecision( + served=True, + terminal="QUESTION_NEEDED", + surface=question_text, + disposition=disposition, + ) + + +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, + ) diff --git a/core/physics/identity.py b/core/physics/identity.py index d2968be5..981285aa 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -298,6 +298,7 @@ class TurnEvent: epistemic_state: str = "undetermined" normative_clearance: str = "unassessable" normative_detail: str = "" + disposition: str = "" # ADR-0206 — Response Governance Bridge reach level for this turn. # The reach policy that governed the response surface, as a # lower_snake_case string mirroring core.response_governance.ReachLevel diff --git a/tests/test_ask_pass_manager_delivery.py b/tests/test_ask_pass_manager_delivery.py index 97fa6645..8eaf1d75 100644 --- a/tests/test_ask_pass_manager_delivery.py +++ b/tests/test_ask_pass_manager_delivery.py @@ -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)) for node in ast.walk(tree): - # Ensure render_question is not imported, and chat/chat.runtime is not imported - if isinstance(node, ast.ImportFrom): + # Ensure render_question is not imported, and chat/chat.runtime/chat.* is not imported + if isinstance(node, ast.ImportFrom) and node.module: assert node.module != "core.epistemic_questions.render" - assert node.module != "chat.runtime" - assert node.module != "chat" + assert node.module != "chat" and not node.module.startswith("chat.") if node.names: for alias in node.names: assert alias.name != "render_question" elif isinstance(node, ast.Import): for alias in node.names: assert alias.name != "core.epistemic_questions.render" - assert alias.name != "chat.runtime" - assert alias.name != "chat" + assert alias.name != "chat" and not alias.name.startswith("chat.") # Ensure render_question is not called directly if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): diff --git a/tests/test_ask_serving_integration.py b/tests/test_ask_serving_integration.py new file mode 100644 index 00000000..346ec56e --- /dev/null +++ b/tests/test_ask_serving_integration.py @@ -0,0 +1,324 @@ +"""Focused integration tests for the Stage 2 served ASK slice. + +Covers the 6 required test cases ensuring gated served ASK, fallback preservation, +unrenderable ask safety, distinct sinks/paths, no prose construction, and governance bus usage. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +from typing import Any + +import pytest + +from chat.runtime import ChatRuntime, ChatResponse +from core.config import RuntimeConfig +from core.epistemic_disclosure.ask_serving import evaluate_served_ask, ServedAskDecision +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: + 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, + ): + self.terminal = DummyTerminal(terminal) if isinstance(terminal, str) else terminal + self.question_path = question_path + self.proposal_path = proposal_path + self.family = family + + +def _write_valid_artifact(path: Path, text: str = "How many chickens are there in total?") -> None: + data = { + "status": "question_only", + "blocking_reason": "missing_total_count", + "owner_organ": "r2_constraint", + "question": { + "text": text, + "reason": "missing_total_count", + "slot_name": "total_count", + }, + "requires_review": True, + "served": False, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def _get_dummy_field_state() -> FieldState: + from field.state import FieldState + # 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), + 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 + 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, + ) + + 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 == "How many chickens are there in total?" + assert len(calls) == 1 + assert calls[0][1]["epistemic_state"] == "undetermined" # maps to EpistemicState.UNDETERMINED + assert calls[0][1]["limitation"].resolution_action == "ask_question" diff --git a/tests/test_question_delivery.py b/tests/test_question_delivery.py index 17904518..55f64511 100644 --- a/tests/test_question_delivery.py +++ b/tests/test_question_delivery.py @@ -217,20 +217,25 @@ def test_question_path_is_content_addressed(tmp_path: Path) -> None: def test_delivery_is_off_serving() -> None: - """The delivery module must not import the sealed GSM8K serving substrate.""" - path = Path(__file__).resolve().parents[1] / "core" / "epistemic_questions" / "delivery.py" - forbidden = ("generate.derivation", "core.reliability_gate") - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module: - assert not any( - node.module == f or node.module.startswith(f + ".") for f in forbidden - ), f"delivery.py imports forbidden serving module {node.module}" - elif isinstance(node, ast.Import): - for alias in node.names: + """The delivery and ask_serving modules must not import the sealed GSM8K serving substrate or chat.""" + repo_root = Path(__file__).resolve().parents[1] + paths = [ + repo_root / "core" / "epistemic_questions" / "delivery.py", + repo_root / "core" / "epistemic_disclosure" / "ask_serving.py", + ] + forbidden = ("generate.derivation", "core.reliability_gate", "chat") + for path in paths: + 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( - alias.name == f or alias.name.startswith(f + ".") for f in forbidden - ), f"delivery.py imports forbidden serving module {alias.name}" + node.module == f or node.module.startswith(f + ".") for f in forbidden + ), 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: