feat(epistemic-disclosure): P0-2 — the DisclosureClaim axis

A served response carries two ORTHOGONAL governance properties: ReachLevel (how
far past grounded fact it reaches) and DisclosureClaim (the epistemic claim it
makes about its own truth status). "verified" is NOT a reach level — it is a
proof-state claim. Keeping the axes separate is the locked Stage-2 decision (no
ReachLevel.VERIFIED; a proven answer never inherits an [approximate] surface).

core/epistemic_disclosure/disclosure_claim.py:
- DisclosureClaim(str, Enum): none | verified | approximate | proposal_only.
  Default = none. str-valued for stable serialization (EpistemicState convention).
- Discipline "no claim without a producer": every member has a real (APPROXIMATE
  = cognition Step E; PROPOSAL_ONLY = teaching/proposals) or imminent (VERIFIED =
  Phase 1 target) emitter. PROVEN and ESTIMATED are intentionally ABSENT — nothing
  emits them; ESTIMATED is a future split of APPROXIMATE, added only when a real
  estimator producer exists (docs-note only).

Also folds the P0-1 review nit: _KIND_TO_STATE / _ACTION_TO_TERMINAL tightened
from dict[str, ...] to their Literal key types.

9 tests, non-vacuous: default is NONE; verified is not a ReachLevel (structural);
claim axis disjoint from ResolutionAction; PROVEN and ESTIMATED absent; exactly the
approved four; str-enum serialization; module imports nothing (off-serving).

No bus behaviour, no ServedDisposition mapping (that is P0-3). Off-serving; smoke
green (90 passed) locally.
This commit is contained in:
Shay 2026-06-08 15:45:08 -07:00
parent 14d81887df
commit 8811917280
4 changed files with 167 additions and 2 deletions

View file

@ -14,6 +14,10 @@ imports ``generate.derivation`` / ``core.reliability_gate``.
from __future__ import annotations from __future__ import annotations
from core.epistemic_disclosure.disclosure_claim import (
DEFAULT_DISCLOSURE_CLAIM,
DisclosureClaim,
)
from core.epistemic_disclosure.limitation import ( from core.epistemic_disclosure.limitation import (
PENDING_Q1B_RECLASSIFICATION, PENDING_Q1B_RECLASSIFICATION,
LimitationAssessment, LimitationAssessment,
@ -25,7 +29,9 @@ from core.epistemic_disclosure.limitation import (
) )
__all__ = [ __all__ = [
"DEFAULT_DISCLOSURE_CLAIM",
"PENDING_Q1B_RECLASSIFICATION", "PENDING_Q1B_RECLASSIFICATION",
"DisclosureClaim",
"LimitationAssessment", "LimitationAssessment",
"LimitationKind", "LimitationKind",
"ResolutionAction", "ResolutionAction",

View file

@ -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"]

View file

@ -89,7 +89,7 @@ class LimitationAssessment:
#: ``SCOPE_BOUNDARY`` state with a real producer; ``contradiction`` / ``ambiguous`` #: ``SCOPE_BOUNDARY`` state with a real producer; ``contradiction`` / ``ambiguous``
#: reuse the ACTIVE states. The refuse/propose kinds share ``UNDETERMINED`` — the #: reuse the ACTIVE states. The refuse/propose kinds share ``UNDETERMINED`` — the
#: *kind* carries the distinction, the *state* only says "no answer determined". #: *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, "missing_information": EpistemicState.UNDETERMINED,
"ambiguous_structure": EpistemicState.AMBIGUOUS, "ambiguous_structure": EpistemicState.AMBIGUOUS,
"scope_boundary": EpistemicState.SCOPE_BOUNDARY, "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). #: 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 #: 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. #: 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, "answer": Terminal.SOLVED_VERIFIED,
"emit_proposal": Terminal.PROPOSAL_EMITTED, "emit_proposal": Terminal.PROPOSAL_EMITTED,
"refuse_known_boundary": Terminal.REFUSED_KNOWN_BOUNDARY, "refuse_known_boundary": Terminal.REFUSED_KNOWN_BOUNDARY,

View file

@ -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