feat(epistemic-disclosure): P0-1 — limitation pass as a consolidating view

The intake gate of the Epistemic Disclosure spine: classify WHAT KIND of
limitation blocks resolution before choosing a served disposition. Asking is
one resolution among six; mis-classifying breaks intake (refuse-when-should-ask
loses the unlocking datum; ask-when-should-propose wastes the channel).

New off-serving package core/epistemic_disclosure/:
- LimitationKind (8) / ResolutionAction (6) / LimitationAssessment
- the mapping DERIVED from the shipped failure_family REGISTRY + contemplation
  Terminal set — a consolidating VIEW, not a fourth taxonomy. The only genuinely
  new action is ask_question (the one action with no shipped terminal); every
  other action maps onto an existing Terminal (terminal_for_action proves it).
- assess_from_family / assess_from_attempt; conservative refuse default
  (an unclassified reason NEVER becomes an answerable question — wrong=0-safe).

The missing_total_count / missing_weighted_total reclassification
(capability_gap -> missing_information) is DEFERRED to Q1-B with its own tests,
not made here; PENDING_Q1B_RECLASSIFICATION + a tripwire test document it.

27 tests, all non-vacuous (each fails under a single mis-keyed entry): totality
over REGISTRY, flag-consistency invariants (proposal<->propose; contradiction
verdict reports; foreign text steps aside), the wrong=0 invariant (hard_boundary
never asks), load-bearing specific mappings, the consolidation proof, and the
attempt-level paths.

Off-serving: imports no generate.derivation / core.reliability_gate (AST-checked);
cannot move the sealed GSM8K metric. Nothing consumes resolution_action yet.
Smoke green (90 passed) locally.
This commit is contained in:
Shay 2026-06-08 15:29:46 -07:00
parent a024ac4d9e
commit 159b6830e4
3 changed files with 494 additions and 0 deletions

View file

@ -0,0 +1,35 @@
"""The Epistemic Disclosure spine (P0-1+).
CORE's served-surface governance: comprehension produces evidence, the limitation
pass classifies *what kind of gap* is blocking resolution, and (in later slices) a
disposition + disclosure-claim decide what reaches the user and under what epistemic
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``.
"""
from __future__ import annotations
from core.epistemic_disclosure.limitation import (
PENDING_Q1B_RECLASSIFICATION,
LimitationAssessment,
LimitationKind,
ResolutionAction,
assess_from_attempt,
assess_from_family,
terminal_for_action,
)
__all__ = [
"PENDING_Q1B_RECLASSIFICATION",
"LimitationAssessment",
"LimitationKind",
"ResolutionAction",
"assess_from_attempt",
"assess_from_family",
"terminal_for_action",
]

View file

@ -0,0 +1,247 @@
"""P0-1 — the pre-question limitation pass, as a CONSOLIDATING VIEW (session §1.5).
The intake gate of the Epistemic Disclosure spine: before contemplation chooses a
served disposition, it classifies *what KIND of limitation* is blocking resolution.
Asking a question is only one possible resolution, and asking is wrong unless the
limitation is specifically the kind a question resolves (missing/ambiguous external
information). Mis-classify and intake breaks two ways refuse-when-should-ask (lose
the unlocking datum) or ask-when-should-propose (waste the channel).
This module is the first slice: the typed vocabulary (:data:`LimitationKind` /
:data:`ResolutionAction` / :class:`LimitationAssessment`) and the *mapping* that
DERIVES each from the already-shipped failure-family registry
(:data:`core.comprehension_attempt.failure_family.REGISTRY`) and contemplation
:class:`~generate.contemplation.findings.Terminal` set.
**Hard invariant (no fourth taxonomy).** Every assessment is mechanically derived
from an existing ``FailureFamily``; the *only* genuinely new resolution action is
``ask_question`` (the future Q1/ASK tenant it is the one action with no shipped
terminal, see :func:`terminal_for_action`). The *only* family reclassification this
design calls for ``missing_total_count`` / ``missing_weighted_total`` from
``capability_gap`` to ``missing_information`` is **deferred** to Q1-B and decided
there with tests, NOT made here (see :data:`PENDING_Q1B_RECLASSIFICATION`). This
slice maps them to their *current* shipped classification.
**Off-serving.** Imports no ``generate.derivation`` / ``core.reliability_gate``; it
cannot move the sealed GSM8K metric. Nothing consumes ``resolution_action`` to change
serving yet this slice only *classifies*.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from core.comprehension_attempt.failure_family import (
FailureFamily,
family_for_reason,
)
from core.comprehension_attempt.model import ComprehensionAttempt
from core.epistemic_state import EpistemicState
from generate.contemplation.findings import Terminal
#: Why resolution is blocked. The split from :data:`ResolutionAction` keeps *state*
#: ("what is true / missing") distinct from *action* ("what to do about it").
LimitationKind = Literal[
"missing_information", # a needed datum is absent — could be supplied → ask
"ambiguous_structure", # data present, relationship unclear → ask (when user-resolvable)
"scope_boundary", # exceeds the current capability/evidence envelope → refuse/explain
"capability_gap", # info present, CORE lacks the transform → propose (or refuse if signal too coarse)
"hard_boundary", # mathematically/logically impossible or undefined → refuse
"contradiction", # evidence conflicts with a claimed answer → report
"renderability_gap", # asking is right but terms aren't grounded enough to name safely → ask-generic
"input_shape", # not this organ's domain → step aside
]
#: What to do about a limitation. ``ask_question`` is the genuinely new action this
#: spine introduces; the other five each correspond to a shipped contemplation
#: terminal (:func:`terminal_for_action`).
ResolutionAction = Literal[
"answer",
"ask_question",
"emit_proposal",
"refuse_known_boundary",
"report_contradiction",
"step_aside",
]
@dataclass(frozen=True, slots=True)
class LimitationAssessment:
"""One typed classification of the limitation blocking a comprehension attempt.
``epistemic_state`` (what is true) and ``resolution_action`` (what to do) are
deliberately distinct: e.g. ``UNDETERMINED`` + ``ask_question`` for a missing
datum. ``blocking_reason`` is the failure-family key (the partition key), so the
assessment is back-traceable to the shipped registry.
"""
limitation_kind: LimitationKind
resolution_action: ResolutionAction
epistemic_state: EpistemicState
owner_organ: str | None
blocking_reason: str
# --- The consolidating mapping (derived from the shipped registry, not invented) ---
#: ``EpistemicState`` each kind asserts. ``scope_boundary`` lights up the RESERVED
#: ``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] = {
"missing_information": EpistemicState.UNDETERMINED,
"ambiguous_structure": EpistemicState.AMBIGUOUS,
"scope_boundary": EpistemicState.SCOPE_BOUNDARY,
"capability_gap": EpistemicState.UNDETERMINED,
"hard_boundary": EpistemicState.UNDETERMINED,
"contradiction": EpistemicState.CONTRADICTED,
"renderability_gap": EpistemicState.UNDETERMINED,
"input_shape": EpistemicState.UNDETERMINED,
}
#: The shipped contemplation ``Terminal`` each action corresponds to. ``ask_question``
#: 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] = {
"answer": Terminal.SOLVED_VERIFIED,
"emit_proposal": Terminal.PROPOSAL_EMITTED,
"refuse_known_boundary": Terminal.REFUSED_KNOWN_BOUNDARY,
"report_contradiction": Terminal.CONTRADICTION_DETECTED,
"step_aside": Terminal.NO_PROGRESS,
"ask_question": None,
}
#: Families Q1-B will reclassify (``capability_gap`` → ``missing_information``): the
#: user CAN supply the total, so it is missing information, not a capability gap. NOT
#: changed here — this slice keeps the current classification; Q1-B flips it with its
#: own tests. Listed so the deferral is explicit and greppable.
PENDING_Q1B_RECLASSIFICATION: frozenset[str] = frozenset(
{"missing_total_count", "missing_weighted_total"}
)
#: family name → (LimitationKind, ResolutionAction). Keys must equal the REGISTRY's
#: family names exactly (asserted total by test). Classification rationale per family:
#: - growth surfaces (``proposal_allowed``) → ``capability_gap`` / ``emit_proposal``
#: - underdetermined / missing-datum refusals → ``missing_information`` / ``ask_question``
#: - same-unit-but-no-cue ambiguity the user could resolve → ``ambiguous_structure`` / ask
#: - math/logic impossibility, incoherence, coarse-signal parse gaps → ``hard_boundary`` / refuse
#: - beyond-current-solver-envelope → ``scope_boundary`` / refuse
#: - the answer-key verdict → ``contradiction`` / ``report_contradiction``
#: - foreign text → ``input_shape`` / ``step_aside``
_FAMILY_TO_LIMITATION: dict[str, tuple[LimitationKind, ResolutionAction]] = {
# cross-organ
"input_shape": ("input_shape", "step_aside"),
"admissibility_incompatible": ("hard_boundary", "refuse_known_boundary"),
# R1
"unsupported_clause_shape": ("capability_gap", "refuse_known_boundary"),
"ungrounded_base": ("missing_information", "ask_question"),
"over_determined": ("hard_boundary", "refuse_known_boundary"),
# R2 boundaries
"unsupported_system_size": ("scope_boundary", "refuse_known_boundary"),
"indistinguishable_system": ("hard_boundary", "refuse_known_boundary"),
"non_integer_solution": ("hard_boundary", "refuse_known_boundary"),
"negative_solution": ("hard_boundary", "refuse_known_boundary"),
"answer_choice_unresolved": ("ambiguous_structure", "refuse_known_boundary"),
# R2 growth surfaces
"missing_total_count": ("capability_gap", "emit_proposal"), # PENDING_Q1B → missing_information/ask
"missing_weighted_total": ("capability_gap", "emit_proposal"), # PENDING_Q1B → missing_information/ask
"missing_category_pair": ("capability_gap", "emit_proposal"),
"missing_attribute_coefficient": ("capability_gap", "emit_proposal"),
# R2 verdict
"answer_key_contradiction": ("contradiction", "report_contradiction"),
# R3
"unsupported_rate_duration": ("capability_gap", "emit_proposal"),
"rate_underdetermined": ("missing_information", "ask_question"),
"unsupported_temporal_state": ("scope_boundary", "refuse_known_boundary"),
# R4 combined-rate boundaries
"cmb_unit_mismatch": ("hard_boundary", "refuse_known_boundary"),
"cmb_combine_ambiguous": ("ambiguous_structure", "ask_question"),
"cmb_underdetermined": ("missing_information", "ask_question"),
"cmb_non_positive_net": ("hard_boundary", "refuse_known_boundary"),
"cmb_non_integer": ("hard_boundary", "refuse_known_boundary"),
# R4 growth surfaces
"cmb_unsupported_rate_count": ("capability_gap", "emit_proposal"),
"cmb_unsupported_reciprocal": ("capability_gap", "emit_proposal"),
"cmb_unsupported_clock_interval": ("capability_gap", "emit_proposal"),
}
# A conservative refusal for any family/reason that is not in the mapping. The total
# mapping (asserted by test against REGISTRY) means this is dead in practice; if a new
# family ever lands unmapped, it refuses — it NEVER silently becomes an answerable
# question (the wrong=0-safe default).
_CONSERVATIVE_DEFAULT: tuple[LimitationKind, ResolutionAction] = (
"hard_boundary",
"refuse_known_boundary",
)
def assess_from_family(family: FailureFamily) -> LimitationAssessment:
"""The limitation a failure family expresses, as a typed assessment.
Total over :data:`REGISTRY` (asserted by test). An unmapped family falls to the
conservative refuse default never ``ask_question``.
"""
kind, action = _FAMILY_TO_LIMITATION.get(family.name, _CONSERVATIVE_DEFAULT)
return LimitationAssessment(
limitation_kind=kind,
resolution_action=action,
epistemic_state=_KIND_TO_STATE[kind],
owner_organ=family.owner,
blocking_reason=family.name,
)
def assess_from_attempt(attempt: ComprehensionAttempt) -> LimitationAssessment | None:
"""Classify the limitation a comprehension attempt expresses, or ``None``.
- ``contradiction`` outcome report it (no refusal reason carries this; it is the
answer-choice verdict).
- a refusal classify via its failure family; an *unclassified* refusal reason
falls to the conservative refuse default (never ``ask_question``).
- any non-limitation outcome (solved/admissible setup, or eval-only ``*_wrong``)
``None``: there is no resolvable limitation to act on.
"""
if attempt.outcome == "contradiction":
return LimitationAssessment(
limitation_kind="contradiction",
resolution_action="report_contradiction",
epistemic_state=EpistemicState.CONTRADICTED,
owner_organ=attempt.organ,
blocking_reason="answer_key_contradiction",
)
if not attempt.is_refusal:
return None
family = family_for_reason(attempt.refusal_reason)
if family is None:
kind, action = _CONSERVATIVE_DEFAULT
return LimitationAssessment(
limitation_kind=kind,
resolution_action=action,
epistemic_state=_KIND_TO_STATE[kind],
owner_organ=attempt.organ,
blocking_reason=attempt.refusal_reason or "unclassified",
)
return assess_from_family(family)
def terminal_for_action(action: ResolutionAction) -> Terminal | None:
"""The shipped contemplation ``Terminal`` an action corresponds to.
``None`` only for ``ask_question`` the one action this spine adds that has no
shipped terminal yet (the Q1/ASK tenant). Every other action maps to an existing
terminal, which is what makes this a consolidating view rather than a new taxonomy.
"""
return _ACTION_TO_TERMINAL[action]
__all__ = [
"PENDING_Q1B_RECLASSIFICATION",
"LimitationAssessment",
"LimitationKind",
"ResolutionAction",
"assess_from_attempt",
"assess_from_family",
"terminal_for_action",
]

View file

@ -0,0 +1,212 @@
"""P0-1 — exhaustive tests for the limitation-assessment consolidating view.
These prove the mapping is (a) TOTAL over the shipped failure-family registry,
(b) CONSISTENT with the registry flags, and (c) NON-VACUOUSLY correct on the
wrong=0-relevant cases: a hard boundary never becomes an answerable question, a
proposal stays a proposal, a contradiction reports, foreign text steps aside. Each
test would fail under a single mis-keyed mapping entry none passes under a broken
impl (CLAUDE.md "Schema-Defined Proof Obligations").
"""
from __future__ import annotations
from typing import get_args
import pytest
from core.comprehension_attempt.failure_family import REGISTRY, family_by_name
from core.comprehension_attempt.model import ComprehensionAttempt
from core.epistemic_disclosure.limitation import (
PENDING_Q1B_RECLASSIFICATION,
LimitationKind,
ResolutionAction,
_FAMILY_TO_LIMITATION,
assess_from_attempt,
assess_from_family,
terminal_for_action,
)
from core.epistemic_state import EpistemicState
from generate.contemplation.findings import Terminal
VALID_KINDS = frozenset(get_args(LimitationKind))
VALID_ACTIONS = frozenset(get_args(ResolutionAction))
REGISTRY_NAMES = frozenset(f.name for f in REGISTRY)
# --- totality + type validity -------------------------------------------------------- #
def test_mapping_is_total_over_registry():
"""Exactly the shipped families — no extra keys, no missing ones."""
assert set(_FAMILY_TO_LIMITATION) == set(REGISTRY_NAMES)
def test_assess_from_family_produces_valid_typed_values():
for family in REGISTRY:
a = assess_from_family(family)
assert a.limitation_kind in VALID_KINDS, family.name
assert a.resolution_action in VALID_ACTIONS, family.name
assert a.blocking_reason == family.name
assert a.owner_organ == family.owner
# --- flag-consistency invariants ----------------------------------------------------- #
def test_proposal_allowed_iff_emit_proposal():
"""INV-A: the growth surfaces — and only those — propose."""
for family in REGISTRY:
a = assess_from_family(family)
assert family.proposal_allowed == (a.resolution_action == "emit_proposal"), family.name
def test_emit_proposal_implies_capability_gap():
for family in REGISTRY:
a = assess_from_family(family)
if a.resolution_action == "emit_proposal":
assert a.limitation_kind == "capability_gap", family.name
def test_report_contradiction_iff_answer_key_contradiction():
"""INV-B: the answer-key verdict is the sole reporter."""
reporters = [f.name for f in REGISTRY if assess_from_family(f).resolution_action == "report_contradiction"]
assert reporters == ["answer_key_contradiction"]
def test_step_aside_iff_input_shape():
"""INV-C: only foreign-shape text steps aside."""
for family in REGISTRY:
a = assess_from_family(family)
is_input_shape = family.name == "input_shape"
assert (a.resolution_action == "step_aside") == is_input_shape, family.name
if is_input_shape:
assert a.limitation_kind == "input_shape"
def test_hard_boundary_never_asks():
"""INV-E (wrong=0-relevant): an impossible problem is never turned into a question."""
for family in REGISTRY:
a = assess_from_family(family)
if a.limitation_kind == "hard_boundary":
assert a.resolution_action == "refuse_known_boundary", family.name
def test_ask_question_only_for_resolvable_kinds():
"""INV-D: asking is licensed only for kinds a user answer could resolve."""
askable = {"missing_information", "ambiguous_structure", "renderability_gap"}
for family in REGISTRY:
a = assess_from_family(family)
if a.resolution_action == "ask_question":
assert a.limitation_kind in askable, family.name
# --- non-vacuous, load-bearing specific mappings ------------------------------------- #
@pytest.mark.parametrize(
"name,kind,action",
[
("cmb_non_positive_net", "hard_boundary", "refuse_known_boundary"),
("cmb_non_integer", "hard_boundary", "refuse_known_boundary"),
("cmb_underdetermined", "missing_information", "ask_question"),
("cmb_combine_ambiguous", "ambiguous_structure", "ask_question"),
("rate_underdetermined", "missing_information", "ask_question"),
("answer_key_contradiction", "contradiction", "report_contradiction"),
("input_shape", "input_shape", "step_aside"),
("cmb_unsupported_reciprocal", "capability_gap", "emit_proposal"),
("unsupported_system_size", "scope_boundary", "refuse_known_boundary"),
("admissibility_incompatible", "hard_boundary", "refuse_known_boundary"),
],
)
def test_specific_load_bearing_mappings(name, kind, action):
family = family_by_name(name)
assert family is not None, name
a = assess_from_family(family)
assert (a.limitation_kind, a.resolution_action) == (kind, action)
def test_pending_reclassification_is_currently_capability_gap():
"""Documents the DEFERRED Q1-B change. Today these propose; Q1-B will flip them to
missing_information/ask. When that lands this test changes deliberately it is the
tripwire proving the reclassification was a conscious, tested act, not a silent re-key."""
for name in PENDING_Q1B_RECLASSIFICATION:
family = family_by_name(name)
assert family is not None, name
a = assess_from_family(family)
assert (a.limitation_kind, a.resolution_action) == ("capability_gap", "emit_proposal"), name
assert PENDING_Q1B_RECLASSIFICATION == {"missing_total_count", "missing_weighted_total"}
# --- the epistemic-state axis -------------------------------------------------------- #
def test_epistemic_state_axis():
by_name = {f.name: assess_from_family(f) for f in REGISTRY}
assert by_name["unsupported_system_size"].epistemic_state == EpistemicState.SCOPE_BOUNDARY
assert by_name["answer_key_contradiction"].epistemic_state == EpistemicState.CONTRADICTED
assert by_name["cmb_combine_ambiguous"].epistemic_state == EpistemicState.AMBIGUOUS
assert by_name["cmb_underdetermined"].epistemic_state == EpistemicState.UNDETERMINED
# --- the consolidation proof: actions live on shipped terminals (except ask) --------- #
def test_actions_consolidate_onto_terminals_except_ask():
"""The proof of 'consolidating view, not a fourth taxonomy': every action maps to a
shipped Terminal except ask_question (the one genuinely new action / Q1 tenant)."""
for action in VALID_ACTIONS:
terminal = terminal_for_action(action)
if action == "ask_question":
assert terminal is None
else:
assert isinstance(terminal, Terminal), action
def test_only_ask_question_is_new():
new_actions = sorted(a for a in VALID_ACTIONS if terminal_for_action(a) is None)
assert new_actions == ["ask_question"]
# --- attempt-level classification ---------------------------------------------------- #
def test_assess_attempt_refusal_underdetermined_asks():
attempt = ComprehensionAttempt(
organ="r4_combined_rate",
outcome="setup_refused",
refusal_reason="cmb_missing_second_rate",
)
a = assess_from_attempt(attempt)
assert a is not None
assert (a.limitation_kind, a.resolution_action) == ("missing_information", "ask_question")
def test_assess_attempt_hard_boundary_refuses_not_asks():
attempt = ComprehensionAttempt(
organ="r4_combined_rate",
outcome="setup_refused",
refusal_reason="cmb_non_positive_net_rate",
)
a = assess_from_attempt(attempt)
assert a is not None
assert a.resolution_action == "refuse_known_boundary"
def test_assess_attempt_contradiction_reports():
attempt = ComprehensionAttempt(organ="r2_constraints", outcome="contradiction")
a = assess_from_attempt(attempt)
assert a is not None
assert (a.limitation_kind, a.resolution_action) == ("contradiction", "report_contradiction")
assert a.epistemic_state == EpistemicState.CONTRADICTED
def test_assess_attempt_solved_is_not_a_limitation():
attempt = ComprehensionAttempt(organ="r1_quantitative", outcome="setup_correct")
assert assess_from_attempt(attempt) is None
def test_assess_attempt_unclassified_reason_refuses_never_asks():
"""wrong=0-safe default: an unknown refusal reason refuses, never becomes a question."""
attempt = ComprehensionAttempt(
organ="r2_constraints",
outcome="setup_refused",
refusal_reason="some_unmapped_future_reason",
)
a = assess_from_attempt(attempt)
assert a is not None
assert a.resolution_action == "refuse_known_boundary"
assert a.resolution_action != "ask_question"