Compare commits
2 commits
add-ask-ac
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a95acb692 | ||
|
|
e4094d81e9 |
8 changed files with 980 additions and 69 deletions
170
chat/runtime.py
170
chat/runtime.py
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -54,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",
|
||||||
|
|
@ -64,6 +68,7 @@ __all__ = [
|
||||||
"LimitationKind",
|
"LimitationKind",
|
||||||
"MissingSlot",
|
"MissingSlot",
|
||||||
"ResolutionAction",
|
"ResolutionAction",
|
||||||
|
"ServedAskDecision",
|
||||||
"ServedDisposition",
|
"ServedDisposition",
|
||||||
"VerificationObligation",
|
"VerificationObligation",
|
||||||
"VerificationProof",
|
"VerificationProof",
|
||||||
|
|
@ -74,5 +79,6 @@ __all__ = [
|
||||||
"choose_served_disposition",
|
"choose_served_disposition",
|
||||||
"disclosure_for_verification",
|
"disclosure_for_verification",
|
||||||
"evaluate_verification",
|
"evaluate_verification",
|
||||||
|
"evaluate_served_ask",
|
||||||
"terminal_for_action",
|
"terminal_for_action",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
204
core/epistemic_disclosure/ask_serving.py
Normal file
204
core/epistemic_disclosure/ask_serving.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Any validation failure causes the adapter to fail closed and return a fallback
|
||||||
|
decision that preserves the existing proposal/refusal surface unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
@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 _validate_artifact(data: Any, q_path: Path, p_path_attr: Any) -> str | None:
|
||||||
|
"""Validate the DeliveredQuestion artifact against the Q1-D contract.
|
||||||
|
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Contract: status == "question_only"
|
||||||
|
if data.get("status") != "question_only":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Contract: requires_review is True (must be present and strictly True)
|
||||||
|
if data.get("requires_review") is not True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Contract: served is False (must be present and strictly False)
|
||||||
|
served_val = data.get("served", _MISSING)
|
||||||
|
if served_val is _MISSING or served_val is not False:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Contract: answer_binding is None (key must be absent or explicitly null)
|
||||||
|
answer_binding_val = data.get("answer_binding", _MISSING)
|
||||||
|
if answer_binding_val is not _MISSING and answer_binding_val is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Contract: question is an object (dict)
|
||||||
|
question_data = data.get("question")
|
||||||
|
if not isinstance(question_data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Contract: question.text is a non-empty string
|
||||||
|
question_text = question_data.get("text")
|
||||||
|
if not isinstance(question_text, str) or not question_text.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
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.
|
||||||
|
# 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:
|
||||||
|
data = json.loads(q_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return _make_fallback_decision(contemplation_result, fallback_surface)
|
||||||
|
|
||||||
|
# 5. Full Q1-D contract validation. _validate_artifact is intentionally
|
||||||
|
# explicit (no single-dict-access shortcut) so each contract clause is
|
||||||
|
# individually testable and auditable.
|
||||||
|
question_text = _validate_artifact(data, q_path, p_path_attr)
|
||||||
|
if question_text is None:
|
||||||
|
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_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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
626
tests/test_ask_serving_integration.py
Normal file
626
tests/test_ask_serving_integration.py
Normal file
|
|
@ -0,0 +1,626 @@
|
||||||
|
"""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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# REVIEWER-REQUIRED REJECTION TESTS (Q1-D artifact contract violations)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _write_artifact(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _question_needed_result(q_path: Path, p_path: Path) -> DummyContemplationResult:
|
||||||
|
return DummyContemplationResult(
|
||||||
|
terminal="QUESTION_NEEDED",
|
||||||
|
question_path=str(q_path),
|
||||||
|
proposal_path=str(p_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_valid_data() -> dict:
|
||||||
|
"""Return a fully-valid DeliveredQuestion artifact payload."""
|
||||||
|
return {
|
||||||
|
"status": "question_only",
|
||||||
|
"requires_review": True,
|
||||||
|
"served": False,
|
||||||
|
"question": {
|
||||||
|
"text": "How many crates are left?",
|
||||||
|
"slot_name": "remaining_crates",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_status_proposal_only(tmp_path) -> None:
|
||||||
|
"""Artifact with status='proposal_only' 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["status"] = "proposal_only"
|
||||||
|
_write_artifact(q_path, data)
|
||||||
|
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
|
||||||
|
assert not decision.served, "status='proposal_only' must not be served"
|
||||||
|
assert decision.surface == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_served_true(tmp_path) -> None:
|
||||||
|
"""Artifact with served=True must not be served (already delivered)."""
|
||||||
|
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
|
||||||
|
_write_artifact(q_path, data)
|
||||||
|
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
|
||||||
|
assert not decision.served, "served=True in artifact must not be served"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_requires_review_false(tmp_path) -> None:
|
||||||
|
"""Artifact with requires_review=False 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["requires_review"] = False
|
||||||
|
_write_artifact(q_path, data)
|
||||||
|
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
|
||||||
|
assert not decision.served, "requires_review=False must not be served"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_non_null_answer_binding(tmp_path) -> None:
|
||||||
|
"""Artifact with answer_binding != None 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["answer_binding"] = {"slot": "remaining_crates", "value": 42}
|
||||||
|
_write_artifact(q_path, data)
|
||||||
|
decision = evaluate_served_ask(config, _question_needed_result(q_path, p_path), "fallback")
|
||||||
|
assert not decision.served, "non-null answer_binding must not be served"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_missing_question_text(tmp_path) -> None:
|
||||||
|
"""Artifact with question.text absent 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()
|
||||||
|
del data["question"]["text"]
|
||||||
|
_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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_blank_question_text(tmp_path) -> None:
|
||||||
|
"""Artifact with question.text blank/whitespace-only 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"]["text"] = " "
|
||||||
|
_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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_missing_slot_name(tmp_path) -> None:
|
||||||
|
"""Artifact with question.slot_name absent must not be served (Q1-C contract)."""
|
||||||
|
config = RuntimeConfig(ask_serving_enabled=True)
|
||||||
|
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)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_empty_slot_name(tmp_path) -> None:
|
||||||
|
"""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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue