diff --git a/core/epistemic_disclosure/__init__.py b/core/epistemic_disclosure/__init__.py index 39bfa39e..c0379ba9 100644 --- a/core/epistemic_disclosure/__init__.py +++ b/core/epistemic_disclosure/__init__.py @@ -14,6 +14,10 @@ imports ``generate.derivation`` / ``core.reliability_gate``. from __future__ import annotations +from core.epistemic_disclosure.disclosure_claim import ( + DEFAULT_DISCLOSURE_CLAIM, + DisclosureClaim, +) from core.epistemic_disclosure.limitation import ( PENDING_Q1B_RECLASSIFICATION, LimitationAssessment, @@ -25,7 +29,9 @@ from core.epistemic_disclosure.limitation import ( ) __all__ = [ + "DEFAULT_DISCLOSURE_CLAIM", "PENDING_Q1B_RECLASSIFICATION", + "DisclosureClaim", "LimitationAssessment", "LimitationKind", "ResolutionAction", diff --git a/core/epistemic_disclosure/disclosure_claim.py b/core/epistemic_disclosure/disclosure_claim.py new file mode 100644 index 00000000..7451b341 --- /dev/null +++ b/core/epistemic_disclosure/disclosure_claim.py @@ -0,0 +1,65 @@ +"""P0-2 — the DisclosureClaim axis (the epistemic claim a served response makes). + +A served response carries two ORTHOGONAL governance properties: + + * ``ReachLevel`` (``core/response_governance/policy.py``) — how far PAST + fully-grounded fact the response reaches (STRICT < APPROXIMATE < EXTRAPOLATE + < CREATIVE). + * ``DisclosureClaim`` (here) — the EPISTEMIC CLAIM the response makes about its + own truth status. + +These are deliberately separate axes. ``verified`` is **not** a reach level — it is +a claim about *proof state*, not about how far the response speculates. Conflating +them (e.g. a hypothetical ``ReachLevel.VERIFIED``) would let a proven answer inherit +an "approximate" surface, or an approximation inherit a "verified" badge. Keeping the +axes distinct is the architectural commitment behind the Stage-2 lockfile +(``docs/analysis/stage2-epistemic-disclosure-bus-verified-v1-scoping-2026-06-08.md`` §0). + +**Discipline — no claim without a producer** (the spine enforces on itself what it +enforces on answers). Every member has a real or imminent emitter: + + * ``NONE`` — every response today (the baseline; STRICT + NONE). + * ``APPROXIMATE`` — active: the cognition Step-E disclosed estimate + (``estimation_enabled`` / ADR-0206 §5). + * ``PROPOSAL_ONLY`` — active: ``teaching/proposals`` emits review-only proposals. + * ``VERIFIED`` — the imminent frontier: its producer is Phase 1 (P1-A..), + declared because it is the v1 target the bus is built around. + +Two claims are intentionally ABSENT, because nothing can emit them — and the spine +will not declare a label it cannot earn: + + * ``PROVEN`` — a claim stronger than VERIFIED; no plan to build a producer. + * ``ESTIMATED`` — a *future* split of ``APPROXIMATE`` into a distinct + numeric-estimate claim, added ONLY once a real estimator producer + exists. Until then the cognition estimate is ``APPROXIMATE``. + +P0-2 ships ONLY the axis + its default. No bus behaviour, no mapping to a disposition +(that is P0-3 / ServedDisposition). Off-serving: this module imports nothing. +""" + +from __future__ import annotations + +from enum import Enum, unique + + +@unique +class DisclosureClaim(str, Enum): + """The epistemic claim a served surface makes about its own truth status. + + Orthogonal to ``ReachLevel``. ``str``-valued for stable serialization into + telemetry / disposition records (the same convention as ``EpistemicState``). + """ + + NONE = "none" # no epistemic claim beyond the plain surface (the default) + VERIFIED = "verified" # independently confirmed under a canonical-comparison contract + APPROXIMATE = "approximate" # a disclosed best-estimate from incomplete evidence + PROPOSAL_ONLY = "proposal_only" # offered as a proposal for review, not asserted + + +#: The default claim: a surface asserts nothing about its truth status unless a +#: producer upgrades it. ``STRICT`` reach + ``NONE`` claim is today's every-response +#: baseline. +DEFAULT_DISCLOSURE_CLAIM: DisclosureClaim = DisclosureClaim.NONE + + +__all__ = ["DEFAULT_DISCLOSURE_CLAIM", "DisclosureClaim"] diff --git a/core/epistemic_disclosure/limitation.py b/core/epistemic_disclosure/limitation.py index 47191eb0..1df66886 100644 --- a/core/epistemic_disclosure/limitation.py +++ b/core/epistemic_disclosure/limitation.py @@ -89,7 +89,7 @@ class LimitationAssessment: #: ``SCOPE_BOUNDARY`` state with a real producer; ``contradiction`` / ``ambiguous`` #: reuse the ACTIVE states. The refuse/propose kinds share ``UNDETERMINED`` — the #: *kind* carries the distinction, the *state* only says "no answer determined". -_KIND_TO_STATE: dict[str, EpistemicState] = { +_KIND_TO_STATE: dict[LimitationKind, EpistemicState] = { "missing_information": EpistemicState.UNDETERMINED, "ambiguous_structure": EpistemicState.AMBIGUOUS, "scope_boundary": EpistemicState.SCOPE_BOUNDARY, @@ -104,7 +104,7 @@ _KIND_TO_STATE: dict[str, EpistemicState] = { #: is ``None`` — it is the ONE action the spine adds that has no terminal yet (Q1/ASK). #: This map is the proof that the limitation pass is a consolidating view, not a new #: universe: five of six actions already exist as terminals. -_ACTION_TO_TERMINAL: dict[str, Terminal | None] = { +_ACTION_TO_TERMINAL: dict[ResolutionAction, Terminal | None] = { "answer": Terminal.SOLVED_VERIFIED, "emit_proposal": Terminal.PROPOSAL_EMITTED, "refuse_known_boundary": Terminal.REFUSED_KNOWN_BOUNDARY, diff --git a/tests/test_disclosure_claim.py b/tests/test_disclosure_claim.py new file mode 100644 index 00000000..09e9a73f --- /dev/null +++ b/tests/test_disclosure_claim.py @@ -0,0 +1,94 @@ +"""P0-2 — tests for the DisclosureClaim axis. + +Each test guards a specific confusion: that ``verified`` is a reach level, that the +claim axis is entangled with the resolution-action axis, that a value with no +producer (``PROVEN``) crept in, or that the default is anything but ``NONE``. None +passes under the confusion it is written to catch. +""" + +from __future__ import annotations + +import ast +import pathlib +from enum import Enum +from typing import get_args + +import core.epistemic_disclosure.disclosure_claim as claim_module +from core.epistemic_disclosure.disclosure_claim import ( + DEFAULT_DISCLOSURE_CLAIM, + DisclosureClaim, +) +from core.epistemic_disclosure.limitation import ResolutionAction +from core.response_governance.policy import ReachLevel + + +def test_default_claim_is_none(): + assert DEFAULT_DISCLOSURE_CLAIM is DisclosureClaim.NONE + + +def test_verified_is_not_a_reach_level(): + """The locked Stage-2 decision: VERIFIED is an epistemic claim, never a reach.""" + assert not any(level.name == "VERIFIED" for level in ReachLevel) + assert "verified" not in {level.value for level in ReachLevel} + assert not isinstance(DisclosureClaim.VERIFIED, ReachLevel) + assert DisclosureClaim is not ReachLevel + + +def test_disclosure_claim_independent_of_resolution_action(): + """Orthogonal axes: disjoint value spaces (knowing the action doesn't fix the claim).""" + claim_values = {c.value for c in DisclosureClaim} + action_values = set(get_args(ResolutionAction)) + assert claim_values.isdisjoint(action_values) + + +def test_no_proven_claim_without_a_producer(): + """Discipline: the spine enforces on itself what it enforces on answers.""" + assert not hasattr(DisclosureClaim, "PROVEN") + + +def test_members_are_exactly_the_approved_four(): + # Four claims, each with a real or imminent producer. ESTIMATED and PROVEN are + # intentionally absent (no producer → no declared label). + assert {c.name for c in DisclosureClaim} == { + "NONE", + "VERIFIED", + "APPROXIMATE", + "PROPOSAL_ONLY", + } + + +def test_estimated_absent_no_claim_without_a_producer(): + # ESTIMATED is a future split of APPROXIMATE; not declared until a producer exists. + assert not hasattr(DisclosureClaim, "ESTIMATED") + + +def test_values_serialize_stably_as_snake_case(): + for c in DisclosureClaim: + assert c.value == c.value.lower() + assert " " not in c.value + assert "-" not in c.value + + +def test_is_str_enum_for_stable_serialization(): + assert issubclass(DisclosureClaim, str) + assert issubclass(DisclosureClaim, Enum) + # str-valued: a member compares/serializes as its value + assert DisclosureClaim.VERIFIED == "verified" + + +def test_module_is_off_serving_and_dependency_free(): + """P0-2 is the bare axis: it imports nothing, so it trivially cannot touch serving.""" + module_path = claim_module.__file__ + assert module_path is not None + src = pathlib.Path(module_path).read_text() + tree = ast.parse(src) + from_imports = [n.module or "" for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)] + plain_imports = [a.name for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names] + all_modules = from_imports + plain_imports + forbidden = [ + m for m in all_modules + if "generate.derivation" in m or "reliability_gate" in m or m.endswith("verify") + ] + assert forbidden == [] + # the only import is the stdlib enum — no project coupling at all (P0-2 is the bare axis) + assert all(m in {"", "__future__", "enum"} for m in all_modules), all_modules