feat(ask): activate gated served ASK through disclosure bus
This commit is contained in:
parent
426adbd197
commit
e4094d81e9
8 changed files with 586 additions and 69 deletions
147
chat/runtime.py
147
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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
135
core/epistemic_disclosure/ask_serving.py
Normal file
135
core/epistemic_disclosure/ask_serving.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
324
tests/test_ask_serving_integration.py
Normal file
324
tests/test_ask_serving_integration.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue