From 159b6830e4491b7e33daa033a094a455f36dfe83 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 8 Jun 2026 15:29:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(epistemic-disclosure):=20P0-1=20=E2=80=94?= =?UTF-8?q?=20limitation=20pass=20as=20a=20consolidating=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/epistemic_disclosure/__init__.py | 35 ++++ core/epistemic_disclosure/limitation.py | 247 ++++++++++++++++++++++++ tests/test_limitation_assessment.py | 212 ++++++++++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 core/epistemic_disclosure/__init__.py create mode 100644 core/epistemic_disclosure/limitation.py create mode 100644 tests/test_limitation_assessment.py diff --git a/core/epistemic_disclosure/__init__.py b/core/epistemic_disclosure/__init__.py new file mode 100644 index 00000000..39bfa39e --- /dev/null +++ b/core/epistemic_disclosure/__init__.py @@ -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", +] diff --git a/core/epistemic_disclosure/limitation.py b/core/epistemic_disclosure/limitation.py new file mode 100644 index 00000000..47191eb0 --- /dev/null +++ b/core/epistemic_disclosure/limitation.py @@ -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", +] diff --git a/tests/test_limitation_assessment.py b/tests/test_limitation_assessment.py new file mode 100644 index 00000000..380023aa --- /dev/null +++ b/tests/test_limitation_assessment.py @@ -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"