diff --git a/core/epistemic_disclosure/__init__.py b/core/epistemic_disclosure/__init__.py index c0379ba9..fd097981 100644 --- a/core/epistemic_disclosure/__init__.py +++ b/core/epistemic_disclosure/__init__.py @@ -6,10 +6,19 @@ disposition + disclosure-claim decide what reaches the user and under what epist claim. This package is the *owner* of that machine — ``generate/derivation/verify.py`` and other serving sites may eventually *consume* it, but must never *define* it. -P0-1 ships only :mod:`core.epistemic_disclosure.limitation` — the typed limitation -vocabulary and its mapping as a CONSOLIDATING VIEW over the shipped failure-family -registry + contemplation terminals (no fourth taxonomy). Off-serving: nothing here -imports ``generate.derivation`` / ``core.reliability_gate``. +Shipped so far (all off-serving — nothing here imports ``generate.derivation`` / +``core.reliability_gate``): + + * :mod:`~core.epistemic_disclosure.limitation` (P0-1) — the typed limitation + vocabulary, a CONSOLIDATING VIEW over the shipped failure-family registry + + contemplation terminals (no fourth taxonomy). + * :mod:`~core.epistemic_disclosure.disclosure_claim` (P0-2) — the ``DisclosureClaim`` + axis (the epistemic claim a response makes), kept SEPARATE from ``ReachLevel``. + * :mod:`~core.epistemic_disclosure.disposition` (P0-3) — ``ServedDisposition`` and + ``choose_served_disposition``: the pure mapping + ``EpistemicState × LimitationAssessment × DisclosureClaim → ServedDisposition``. + Mapping scaffold only — no rendering, no bus, no ``verify.py``; nothing consumes + it yet. """ from __future__ import annotations @@ -18,6 +27,10 @@ from core.epistemic_disclosure.disclosure_claim import ( DEFAULT_DISCLOSURE_CLAIM, DisclosureClaim, ) +from core.epistemic_disclosure.disposition import ( + ServedDisposition, + choose_served_disposition, +) from core.epistemic_disclosure.limitation import ( PENDING_Q1B_RECLASSIFICATION, LimitationAssessment, @@ -35,7 +48,9 @@ __all__ = [ "LimitationAssessment", "LimitationKind", "ResolutionAction", + "ServedDisposition", "assess_from_attempt", "assess_from_family", + "choose_served_disposition", "terminal_for_action", ] diff --git a/core/epistemic_disclosure/disposition.py b/core/epistemic_disclosure/disposition.py new file mode 100644 index 00000000..b39cb87e --- /dev/null +++ b/core/epistemic_disclosure/disposition.py @@ -0,0 +1,110 @@ +"""P0-3 — ServedDisposition: the served-surface decision (mapping scaffold only). + +The third axis of the Epistemic Disclosure bus. Given (a) the epistemic state, (b) +the limitation assessment (if resolution is blocked), and (c) the disclosure claim, +:func:`choose_served_disposition` decides WHAT KIND OF MOVE the served surface makes: +commit / disclose / ask / propose / report / explain / refuse / step-aside. + +This is a PURE MAPPING. No rendering, no bus behaviour, no ``verify.py``, no +``govern_response`` mutation — nothing consumes the result yet. P0-3 only fixes the +decision table so a later slice / Phase 1 can wire it. + +The load-bearing rule (the Phase-1 guard): a ``DisclosureClaim.VERIFIED`` discloses +ONLY when the epistemic state is actually ``EpistemicState.VERIFIED``. An unbacked +verified claim degrades to a plain ``COMMIT`` — it is NEVER served as verified before +the producer exists. This protects the whole VERIFIED lane from accidental +"verified-looking" serving. + +Off-serving: imports no ``generate.derivation`` / ``core.reliability_gate``. +""" + +from __future__ import annotations + +from enum import Enum, unique + +from core.epistemic_disclosure.disclosure_claim import DisclosureClaim +from core.epistemic_disclosure.limitation import LimitationAssessment +from core.epistemic_state import EpistemicState + + +@unique +class ServedDisposition(str, Enum): + """What kind of move the served surface makes. + + ``str``-valued for stable serialization (the ``EpistemicState`` / + ``DisclosureClaim`` convention). + """ + + COMMIT = "commit" # serve a fully-grounded answer as-is, no epistemic caveat + DISCLOSE = "disclose" # serve under a disclosure claim ([verified] / [approximate]) + ASK = "ask" # ask the user for the missing/ambiguous datum (Q1 tenant) + PROPOSE = "propose" # offer a review-only capability proposal, do not assert + REPORT = "report" # report a contradiction with a supplied answer key + EXPLAIN = "explain" # explain that the ask is outside the current envelope (scope) + REFUSE = "refuse" # hard-stop refusal (impossible / unreadable / known boundary) + STEP_ASIDE = "step_aside" # not this organ's domain — cede + + +def choose_served_disposition( + *, + epistemic_state: EpistemicState, + limitation: LimitationAssessment | None, + disclosure_claim: DisclosureClaim = DisclosureClaim.NONE, +) -> ServedDisposition: + """Decide the served disposition. Pure, deterministic, total. + + A blocking limitation dominates — you do not serve an answer, you ask / propose / + report / explain / refuse / step aside per its resolution action. (A limitation + whose action is ``answer`` is non-blocking and falls through to the serve + decision.) With no blocking limitation, the disclosure claim + epistemic state pick + the serve mode, under the Phase-1 ``VERIFIED`` guard. + + NOTE (scaffold trust boundary): a blocking epistemic state (CONTRADICTED, + AMBIGUOUS, UNDETERMINED) reaches this function AS a limitation (e.g. a contradiction + arrives as ``report_contradiction``), not as ``limitation=None``. The serve branch + trusts ``limitation=None`` to mean "servable". + """ + if limitation is not None: + match limitation.resolution_action: + case "ask_question": + return ServedDisposition.ASK + case "emit_proposal": + return ServedDisposition.PROPOSE + case "report_contradiction": + return ServedDisposition.REPORT + case "step_aside": + return ServedDisposition.STEP_ASIDE + case "refuse_known_boundary": + # scope_boundary is the governed "outside the current envelope" + # disposition — it may render as a refusal later, but must NOT collapse + # into a hard boundary here. + if limitation.limitation_kind == "scope_boundary": + return ServedDisposition.EXPLAIN + return ServedDisposition.REFUSE + case "answer": + pass # non-blocking — fall through to the serve decision + + return _serve_disposition(epistemic_state, disclosure_claim) + + +def _serve_disposition( + epistemic_state: EpistemicState, disclosure_claim: DisclosureClaim +) -> ServedDisposition: + """The no-blocking-limitation branch: claim + state pick the serve mode.""" + match disclosure_claim: + case DisclosureClaim.VERIFIED: + # The Phase-1 guard: disclose-as-verified ONLY with the backing state. + if epistemic_state is EpistemicState.VERIFIED: + return ServedDisposition.DISCLOSE + return ServedDisposition.COMMIT + case DisclosureClaim.APPROXIMATE: + return ServedDisposition.DISCLOSE + case DisclosureClaim.PROPOSAL_ONLY: + return ServedDisposition.PROPOSE + case DisclosureClaim.NONE: + return ServedDisposition.COMMIT + case _: # pragma: no cover - exhaustive over the 4-member enum; loud if extended + raise AssertionError(f"unhandled disclosure_claim: {disclosure_claim!r}") + + +__all__ = ["ServedDisposition", "choose_served_disposition"] diff --git a/tests/test_served_disposition.py b/tests/test_served_disposition.py new file mode 100644 index 00000000..eb2b0e83 --- /dev/null +++ b/tests/test_served_disposition.py @@ -0,0 +1,189 @@ +"""P0-3 — tests for the ServedDisposition mapping scaffold. + +Mapping-only: no rendering, no bus, no serving. Tests assert the load-bearing rules, +the Phase-1 VERIFIED guard (a verified claim never discloses without the verified +state), limitation-dominates-claim, the explicit scope_boundary -> EXPLAIN choice, +and totality over both axes. +""" + +from __future__ import annotations + +import ast +import pathlib +from enum import Enum + +import pytest + +import core.epistemic_disclosure.disposition as disposition_module +from core.comprehension_attempt.failure_family import REGISTRY, family_by_name +from core.epistemic_disclosure.disclosure_claim import DisclosureClaim +from core.epistemic_disclosure.disposition import ( + ServedDisposition, + choose_served_disposition, +) +from core.epistemic_disclosure.limitation import LimitationAssessment, assess_from_family +from core.epistemic_state import EpistemicState + + +def _disp_for_family(name, *, state=EpistemicState.UNDETERMINED, claim=DisclosureClaim.NONE): + family = family_by_name(name) + assert family is not None, name + return choose_served_disposition( + epistemic_state=state, limitation=assess_from_family(family), disclosure_claim=claim + ) + + +# --- serve branch (no blocking limitation) ----------------------------------------- # + +def test_no_limitation_none_claim_commits(): + assert ( + choose_served_disposition( + epistemic_state=EpistemicState.DECODED, + limitation=None, + disclosure_claim=DisclosureClaim.NONE, + ) + is ServedDisposition.COMMIT + ) + + +def test_verified_state_and_claim_discloses(): + assert ( + choose_served_disposition( + epistemic_state=EpistemicState.VERIFIED, + limitation=None, + disclosure_claim=DisclosureClaim.VERIFIED, + ) + is ServedDisposition.DISCLOSE + ) + + +@pytest.mark.parametrize( + "state", [s for s in EpistemicState if s is not EpistemicState.VERIFIED] +) +def test_verified_claim_without_verified_state_never_discloses(state): + """THE Phase-1 guard: a verified claim must not produce DISCLOSE unless the state + is actually VERIFIED — protects the lane from 'verified-looking' serving before the + producer exists.""" + disp = choose_served_disposition( + epistemic_state=state, limitation=None, disclosure_claim=DisclosureClaim.VERIFIED + ) + assert disp is not ServedDisposition.DISCLOSE + assert disp is ServedDisposition.COMMIT + + +def test_approximate_claim_discloses(): + assert ( + choose_served_disposition( + epistemic_state=EpistemicState.EVIDENCED_INCOMPLETE, + limitation=None, + disclosure_claim=DisclosureClaim.APPROXIMATE, + ) + is ServedDisposition.DISCLOSE + ) + + +def test_proposal_only_claim_proposes(): + assert ( + choose_served_disposition( + epistemic_state=EpistemicState.UNDETERMINED, + limitation=None, + disclosure_claim=DisclosureClaim.PROPOSAL_ONLY, + ) + is ServedDisposition.PROPOSE + ) + + +# --- limitation-driven dispositions (via real P0-1 assessments) -------------------- # + +def test_ask_question_limitation_asks(): + assert _disp_for_family("cmb_underdetermined") is ServedDisposition.ASK + + +def test_emit_proposal_limitation_proposes(): + assert _disp_for_family("cmb_unsupported_reciprocal") is ServedDisposition.PROPOSE + + +def test_report_contradiction_limitation_reports(): + assert _disp_for_family("answer_key_contradiction") is ServedDisposition.REPORT + + +def test_step_aside_limitation_steps_aside(): + assert _disp_for_family("input_shape") is ServedDisposition.STEP_ASIDE + + +def test_hard_boundary_limitation_refuses(): + assert _disp_for_family("cmb_non_positive_net") is ServedDisposition.REFUSE + + +def test_scope_boundary_limitation_explains_not_refuses(): + """The explicit P0-3 choice: scope is EXPLAIN, not a hard REFUSE.""" + disp = _disp_for_family("unsupported_system_size") + assert disp is ServedDisposition.EXPLAIN + assert disp is not ServedDisposition.REFUSE + + +def test_limitation_dominates_disclosure_claim(): + """Even a fully-backed verified claim cannot serve an answer when a limitation blocks.""" + family = family_by_name("cmb_underdetermined") + assert family is not None + disp = choose_served_disposition( + epistemic_state=EpistemicState.VERIFIED, + limitation=assess_from_family(family), + disclosure_claim=DisclosureClaim.VERIFIED, + ) + assert disp is ServedDisposition.ASK + + +def test_answer_action_falls_through_to_serve(): + """A non-blocking limitation (resolution_action == 'answer') yields the serve decision.""" + la = LimitationAssessment( + limitation_kind="missing_information", + resolution_action="answer", + epistemic_state=EpistemicState.UNDETERMINED, + owner_organ="r2", + blocking_reason="synthetic", + ) + assert ( + choose_served_disposition( + epistemic_state=EpistemicState.DECODED, + limitation=la, + disclosure_claim=DisclosureClaim.NONE, + ) + is ServedDisposition.COMMIT + ) + + +# --- totality over both axes ------------------------------------------------------- # + +def test_every_family_maps_to_a_disposition(): + for family in REGISTRY: + disp = choose_served_disposition( + epistemic_state=EpistemicState.UNDETERMINED, limitation=assess_from_family(family) + ) + assert isinstance(disp, ServedDisposition), family.name + + +def test_every_disclosure_claim_serves_to_a_disposition(): + for claim in DisclosureClaim: + disp = choose_served_disposition( + epistemic_state=EpistemicState.VERIFIED, limitation=None, disclosure_claim=claim + ) + assert isinstance(disp, ServedDisposition), claim + + +# --- hygiene ----------------------------------------------------------------------- # + +def test_is_str_enum(): + assert issubclass(ServedDisposition, str) + assert issubclass(ServedDisposition, Enum) + + +def test_module_is_off_serving(): + module_path = disposition_module.__file__ + assert module_path is not None + src = pathlib.Path(module_path).read_text() + mods = [n.module or "" for n in ast.walk(ast.parse(src)) if isinstance(n, ast.ImportFrom)] + forbidden = [ + m for m in mods if "generate.derivation" in m or "reliability_gate" in m or m.endswith("verify") + ] + assert forbidden == []