diff --git a/core/epistemic_disclosure/__init__.py b/core/epistemic_disclosure/__init__.py index 90ca640b..57bfc46a 100644 --- a/core/epistemic_disclosure/__init__.py +++ b/core/epistemic_disclosure/__init__.py @@ -36,9 +36,10 @@ from core.epistemic_disclosure.disposition import ( choose_served_disposition, ) from core.epistemic_disclosure.limitation import ( - PENDING_Q1B_RECLASSIFICATION, + Q1B_ASK_CARVE_OUT, LimitationAssessment, LimitationKind, + MissingSlot, ResolutionAction, assess_from_attempt, assess_from_family, @@ -56,11 +57,12 @@ from core.epistemic_disclosure.verified_contract import ( __all__ = [ "DEFAULT_DISCLOSURE_CLAIM", - "PENDING_Q1B_RECLASSIFICATION", + "Q1B_ASK_CARVE_OUT", "VERIFICATION_OBLIGATION", "DisclosureClaim", "LimitationAssessment", "LimitationKind", + "MissingSlot", "ResolutionAction", "ServedDisposition", "VerificationObligation", diff --git a/core/epistemic_disclosure/limitation.py b/core/epistemic_disclosure/limitation.py index 1df66886..b0e35eb9 100644 --- a/core/epistemic_disclosure/limitation.py +++ b/core/epistemic_disclosure/limitation.py @@ -16,20 +16,31 @@ DERIVES each from the already-shipped failure-family registry **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. +terminal, see :func:`terminal_for_action`). + +**Q1-B transitional carve-out (this slice).** Two shipped families — +``missing_total_count`` and ``missing_weighted_total`` — are classified here as +``missing_information`` / ``ask_question``: the user *could state the total* and +unblock solving, so they are missing data, not capability gaps. The shipped +:data:`REGISTRY` still flags them ``proposal_allowed = True`` so that existing +consumers (:mod:`core.comprehension_attempt.proposal` / +:mod:`generate.contemplation.pass_manager`) keep emitting proposal-only artifacts +to the pile — until Q1-C/Q1-D wire ASK delivery to a served surface there is +nowhere for an ``ask_question`` to go, and silently dropping the proposal signal +would be a capability regression with no compensating intake. Once ASK is serving, +the REGISTRY flag flips to ``False`` and the carve-out +(:data:`Q1B_ASK_CARVE_OUT`) retires. The carve-out is named, enumerated, and +covered by an explicit test (``tests/test_limitation_assessment.py``) so its +removal is a conscious act, not a silent re-key. **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*. +serving yet — this slice only *classifies* and *describes residue*. """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Literal from core.comprehension_attempt.failure_family import ( @@ -66,6 +77,26 @@ ResolutionAction = Literal[ ] +@dataclass(frozen=True, slots=True) +class MissingSlot: + """One typed slot the ASK limitation identifies as missing. + + A *structural* description (NOT user-renderable prose): ``slot_name`` is the + family-defined slot identifier (e.g. ``"total_count"``); ``expected_unit_or_type`` + is the family-typed expectation a future bound answer must satisfy (e.g. + ``"count_int"``); ``binding_target`` is the structural role the slot fills in + the organ's setup (e.g. ``"collective_unit_total"``), which Q2 answer-binding + re-enters the gate against (scoping §4). Renderable, user-facing strings come + later from grounded text spans (:attr:`LimitationAssessment.grounded_terms`), + NEVER from these snake_case identifiers — that split is what keeps the renderer + wrong=0-safe (scoping §2). + """ + + slot_name: str + expected_unit_or_type: str + binding_target: str + + @dataclass(frozen=True, slots=True) class LimitationAssessment: """One typed classification of the limitation blocking a comprehension attempt. @@ -74,6 +105,13 @@ class LimitationAssessment: 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. + + ``missing_slots`` and ``grounded_terms`` are the ASK *typed residue* — populated + only for ``ask_question`` resolutions by :func:`assess_from_attempt`. Both + default to empty so existing P0-1 callers using :func:`assess_from_family` + continue to work unchanged. The wrong=0 invariant (scoping §2): a slot here + carries family-typed structural identifiers only; renderable prose must come + from ``grounded_terms`` (verbatim text spans), never fabricated. """ limitation_kind: LimitationKind @@ -81,6 +119,8 @@ class LimitationAssessment: epistemic_state: EpistemicState owner_organ: str | None blocking_reason: str + missing_slots: tuple[MissingSlot, ...] = field(default_factory=tuple) + grounded_terms: tuple[str, ...] = field(default_factory=tuple) # --- The consolidating mapping (derived from the shipped registry, not invented) --- @@ -113,17 +153,23 @@ _ACTION_TO_TERMINAL: dict[ResolutionAction, Terminal | None] = { "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( +#: **Transitional carve-out (Q1-B).** Families this slice classifies as +#: ``ask_question`` in the limitation layer while their shipped +#: :data:`REGISTRY` entries keep ``proposal_allowed = True`` so the contemplation +#: pass and proposal pile keep working unchanged. This is an honest migration seam: +#: the disclosure layer speaks the truthful classification now; the operational +#: layer keeps the current signal so nothing regresses before Q1-C/Q1-D wires ASK +#: delivery. Retirement: once ASK is serving, flip ``proposal_allowed = False`` on +#: these two families in :mod:`core.comprehension_attempt.failure_family`, drop +#: this set, and amend the ``proposal_allowed`` invariant in tests. +Q1B_ASK_CARVE_OUT: 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`` +#: EXCEPT :data:`Q1B_ASK_CARVE_OUT` — see that constant's docstring #: - 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 @@ -144,9 +190,10 @@ _FAMILY_TO_LIMITATION: dict[str, tuple[LimitationKind, ResolutionAction]] = { "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 + # R2 growth surfaces — Q1-B reclassification (see Q1B_ASK_CARVE_OUT): + # disclosure says ask; REGISTRY still emits proposals to the pile. + "missing_total_count": ("missing_information", "ask_question"), + "missing_weighted_total": ("missing_information", "ask_question"), "missing_category_pair": ("capability_gap", "emit_proposal"), "missing_attribute_coefficient": ("capability_gap", "emit_proposal"), # R2 verdict @@ -167,6 +214,34 @@ _FAMILY_TO_LIMITATION: dict[str, tuple[LimitationKind, ResolutionAction]] = { "cmb_unsupported_clock_interval": ("capability_gap", "emit_proposal"), } +#: family name → typed slots an ASK assessment for that family identifies as missing. +#: Only families with a *known* slot structure appear here; an ask-mapped family without +#: an entry yields an empty residue (the "minimal" stance — never fabricate a slot the +#: family's contract has not pinned down yet). Slot semantics, per family: +#: - ``missing_total_count`` : the collective-unit total count (R2 constraint C7); +#: ``binding_target`` matches the equation slot the augmented input would fill +#: (Q2 re-entry, scoping §4). +#: - ``missing_weighted_total``: the measured-unit weighted total (R2 constraint C8). +#: Other ask-mapped families (``ungrounded_base``, ``rate_underdetermined``, +#: ``cmb_underdetermined``, ``cmb_combine_ambiguous``) get slots in later Q1 sub-PRs +#: once their per-family slot signatures are pinned with tests. +_FAMILY_TO_MISSING_SLOTS: dict[str, tuple[MissingSlot, ...]] = { + "missing_total_count": ( + MissingSlot( + slot_name="total_count", + expected_unit_or_type="count_int", + binding_target="collective_unit_total", + ), + ), + "missing_weighted_total": ( + MissingSlot( + slot_name="weighted_total", + expected_unit_or_type="measured_unit_int", + binding_target="weighted_total_value", + ), + ), +} + # 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 @@ -181,7 +256,9 @@ 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``. + conservative refuse default — never ``ask_question``. Residue defaults to empty; + populating typed slots / grounded terms requires a specific attempt (use + :func:`assess_from_attempt`). """ kind, action = _FAMILY_TO_LIMITATION.get(family.name, _CONSERVATIVE_DEFAULT) return LimitationAssessment( @@ -193,6 +270,29 @@ def assess_from_family(family: FailureFamily) -> LimitationAssessment: ) +def _residue_from_attempt( + attempt: ComprehensionAttempt, + family: FailureFamily, + action: ResolutionAction, +) -> tuple[tuple[MissingSlot, ...], tuple[str, ...]]: + """Typed ASK residue for an ask-mapped attempt — empty for any other action. + + Wrong=0 invariant (scoping §2): ``grounded_terms`` carries only verbatim text from + :attr:`ComprehensionAttempt.evidence` SourceSpanLinks. If the attempt carries no + evidence (today: every refused attempt — :mod:`core.comprehension_attempt.classify` + leaves ``evidence = ()``), ``grounded_terms`` is empty, NEVER fabricated from the + family or the refusal reason. ``missing_slots`` is family-derived (snake_case + structural identifiers, not renderable prose) so it is always safe to emit; absent + from :data:`_FAMILY_TO_MISSING_SLOTS` ⇒ no slots (minimal stance — never invent a + slot the family contract has not pinned down). + """ + if action != "ask_question": + return ((), ()) + slots = _FAMILY_TO_MISSING_SLOTS.get(family.name, ()) + grounded = tuple(link.text for link in attempt.evidence) + return (slots, grounded) + + def assess_from_attempt(attempt: ComprehensionAttempt) -> LimitationAssessment | None: """Classify the limitation a comprehension attempt expresses, or ``None``. @@ -202,6 +302,10 @@ def assess_from_attempt(attempt: ComprehensionAttempt) -> LimitationAssessment | 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. + + For ask-mapped refusals, the returned assessment carries typed residue + (:attr:`~LimitationAssessment.missing_slots` / ``grounded_terms``) per + :func:`_residue_from_attempt`'s wrong=0 invariant. """ if attempt.outcome == "contradiction": return LimitationAssessment( @@ -223,7 +327,21 @@ def assess_from_attempt(attempt: ComprehensionAttempt) -> LimitationAssessment | owner_organ=attempt.organ, blocking_reason=attempt.refusal_reason or "unclassified", ) - return assess_from_family(family) + base = assess_from_family(family) + missing_slots, grounded_terms = _residue_from_attempt( + attempt, family, base.resolution_action + ) + if not missing_slots and not grounded_terms: + return base + return LimitationAssessment( + limitation_kind=base.limitation_kind, + resolution_action=base.resolution_action, + epistemic_state=base.epistemic_state, + owner_organ=attempt.organ, + blocking_reason=base.blocking_reason, + missing_slots=missing_slots, + grounded_terms=grounded_terms, + ) def terminal_for_action(action: ResolutionAction) -> Terminal | None: @@ -237,9 +355,10 @@ def terminal_for_action(action: ResolutionAction) -> Terminal | None: __all__ = [ - "PENDING_Q1B_RECLASSIFICATION", + "Q1B_ASK_CARVE_OUT", "LimitationAssessment", "LimitationKind", + "MissingSlot", "ResolutionAction", "assess_from_attempt", "assess_from_family", diff --git a/tests/test_limitation_assessment.py b/tests/test_limitation_assessment.py index 380023aa..c4a45b29 100644 --- a/tests/test_limitation_assessment.py +++ b/tests/test_limitation_assessment.py @@ -6,10 +6,19 @@ 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"). + +Q1-B tests (added 2026-06-08) cover the typed ASK residue (``MissingSlot``, +``grounded_terms``) and the transitional carve-out (:data:`Q1B_ASK_CARVE_OUT`): +the limitation layer classifies ``missing_total_count`` / ``missing_weighted_total`` +as ``ask_question`` while the shipped registry still flags them +``proposal_allowed = True`` so existing proposal consumers keep working until +Q1-C/Q1-D wires ASK delivery. The carve-out is named, enumerated, and explicit. """ from __future__ import annotations +import ast +from pathlib import Path from typing import get_args import pytest @@ -17,15 +26,18 @@ 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, + Q1B_ASK_CARVE_OUT, LimitationKind, + MissingSlot, ResolutionAction, _FAMILY_TO_LIMITATION, + _FAMILY_TO_MISSING_SLOTS, assess_from_attempt, assess_from_family, terminal_for_action, ) from core.epistemic_state import EpistemicState +from generate.binding_graph.model import SourceSpanLink from generate.contemplation.findings import Terminal VALID_KINDS = frozenset(get_args(LimitationKind)) @@ -51,10 +63,21 @@ def test_assess_from_family_produces_valid_typed_values(): # --- flag-consistency invariants ----------------------------------------------------- # -def test_proposal_allowed_iff_emit_proposal(): - """INV-A: the growth surfaces — and only those — propose.""" +def test_proposal_allowed_iff_emit_proposal_with_q1b_ask_carveout(): + """INV-A (amended for Q1-B): the growth surfaces — and only those — propose, + EXCEPT the Q1-B ASK carve-out (:data:`Q1B_ASK_CARVE_OUT`) which carries + ``proposal_allowed = True`` in the REGISTRY (to keep the proposal pile populated + until Q1-C/Q1-D wires ASK delivery) while being classified as ``ask_question`` in + the limitation layer. Retire the carve-out once ASK is serving (then flip the + REGISTRY flag and this test reverts to the pre-Q1B form).""" for family in REGISTRY: a = assess_from_family(family) + if family.name in Q1B_ASK_CARVE_OUT: + # Carve-out: registry still proposal_allowed, limitation says ask. + assert family.proposal_allowed is True, family.name + assert a.resolution_action == "ask_question", family.name + assert a.limitation_kind == "missing_information", family.name + continue assert family.proposal_allowed == (a.resolution_action == "emit_proposal"), family.name @@ -122,16 +145,217 @@ def test_specific_load_bearing_mappings(name, kind, action): 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: +# --- Q1-B reclassification — disclosure layer (named, tested, never silent) ---------- # + +def test_missing_total_count_limitation_is_ask_oriented(): + """Q1-B: ``missing_total_count`` is classified as ``missing_information`` / + ``ask_question`` in the limitation layer — the user CAN state the total. This is + the disclosure-spine's truthful classification; see + :func:`test_registry_proposal_allowed_preserved_until_ask_delivery` for the + transitional carve-out that keeps proposal emission alive until Q1-C/D.""" + family = family_by_name("missing_total_count") + assert family is not None + a = assess_from_family(family) + assert a.limitation_kind == "missing_information" + assert a.resolution_action == "ask_question" + assert a.epistemic_state == EpistemicState.UNDETERMINED + assert a.owner_organ == "r2" + assert a.blocking_reason == "missing_total_count" + + +def test_missing_weighted_total_limitation_is_ask_oriented(): + """Q1-B: ``missing_weighted_total`` is classified as ``missing_information`` / + ``ask_question`` in the limitation layer — the user CAN state the weighted + total. Same carve-out story as ``missing_total_count``.""" + family = family_by_name("missing_weighted_total") + assert family is not None + a = assess_from_family(family) + assert a.limitation_kind == "missing_information" + assert a.resolution_action == "ask_question" + assert a.epistemic_state == EpistemicState.UNDETERMINED + assert a.owner_organ == "r2" + assert a.blocking_reason == "missing_weighted_total" + + +def test_q1b_ask_carveout_is_explicit_and_enumerated(): + """The carve-out set is named, frozen, and contains exactly the two families + Q1-B reclassifies — so its retirement (once ASK is serving) is a conscious, + grep-discoverable act, not a silent re-key.""" + assert Q1B_ASK_CARVE_OUT == {"missing_total_count", "missing_weighted_total"} + assert isinstance(Q1B_ASK_CARVE_OUT, frozenset) + for name in Q1B_ASK_CARVE_OUT: + assert family_by_name(name) is not None, name + + +# --- Q1-B carve-out — no signal loss before ASK delivery ----------------------------- # + +def test_registry_proposal_allowed_preserved_until_ask_delivery(): + """The transitional carve-out's *operational* half: the shipped REGISTRY MUST + still flag ``missing_total_count`` / ``missing_weighted_total`` as + ``proposal_allowed = True`` so existing consumers + (:mod:`core.comprehension_attempt.proposal`, + :mod:`generate.contemplation.pass_manager`) keep emitting proposal-only artifacts + to the pile. Until Q1-C/D wires ASK delivery to a served surface, flipping this + flag would create a no-proposal/no-question dead zone (capability regression). + Retirement: this test inverts once ASK is serving.""" + for name in Q1B_ASK_CARVE_OUT: family = family_by_name(name) assert family is not None, name + assert family.proposal_allowed is True, ( + f"{name}: REGISTRY proposal_allowed flipped before ASK delivery — " + f"would silently drop the proposal pile signal. See Q1B_ASK_CARVE_OUT." + ) + assert family.must_remain_refused is False, name + assert family.proposal_target == "r2_gold_fixture", name + + +def test_no_signal_loss_before_question_bus_is_serving(): + """End-to-end: the contemplation pass MUST still reach ``PROPOSAL_EMITTED`` for + Q1-B-reclassified families until ASK delivery is wired. Proves the disclosure- + layer reclassification did not silently drop proposal emission in the live + contemplation pass — the no-regression guarantee made above.""" + from generate.contemplation.pass_manager import contemplate + + # The R2 fixture for the missing_total_count case is the same one + # tests/test_r3_router_contemplation.py uses for its R3-not-blocking check. + from evals.constraint_oracle.runner import _load_r2_gold + + fx = next(f for f in _load_r2_gold() if f["id"] == "r2-011-missing-total-count") + result = contemplate(fx["text"]) + assert result.terminal == Terminal.PROPOSAL_EMITTED, ( + f"missing_total_count contemplation terminal={result.terminal} — Q1-B carve-out " + f"breach: proposal emission lost before ASK delivery is wired." + ) + assert result.family == "missing_total_count" + + +# --- Q1-B typed residue — MissingSlot + grounded_terms ------------------------------- # + +def test_missing_slots_default_empty_for_non_ask_families(): + """Backward compatibility: ``assess_from_family`` returns empty residue (so + existing P0-1 callers continue to work). Only :func:`assess_from_attempt` for + an ask-mapped attempt populates residue.""" + for family in REGISTRY: 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"} + assert a.missing_slots == () + assert a.grounded_terms == () + + +def test_missing_total_count_has_typed_missing_slot_residue(): + """Ask-mapped attempt for ``missing_total_count`` carries the family's typed + slot (structural identifier — NOT renderable prose). ``grounded_terms`` is empty + because today's :func:`classify_r2` leaves ``evidence = ()``; the wrong=0 + invariant (scoping §2) forbids fabricating renderable terms.""" + attempt = ComprehensionAttempt( + organ="r2_constraints", + outcome="setup_refused", + refusal_reason="missing_total_count", + ) + a = assess_from_attempt(attempt) + assert a is not None + assert a.resolution_action == "ask_question" + assert a.missing_slots == ( + MissingSlot( + slot_name="total_count", + expected_unit_or_type="count_int", + binding_target="collective_unit_total", + ), + ) + assert a.grounded_terms == () + + +def test_missing_weighted_total_has_typed_missing_slot_residue(): + """Same shape as ``missing_total_count``, different slot.""" + attempt = ComprehensionAttempt( + organ="r2_constraints", + outcome="setup_refused", + refusal_reason="missing_weighted_total", + ) + a = assess_from_attempt(attempt) + assert a is not None + assert a.resolution_action == "ask_question" + assert a.missing_slots == ( + MissingSlot( + slot_name="weighted_total", + expected_unit_or_type="measured_unit_int", + binding_target="weighted_total_value", + ), + ) + assert a.grounded_terms == () + + +def test_grounded_terms_populated_only_from_evidence_spans(): + """When the attempt carries evidence, ``grounded_terms`` reads verbatim text + from those spans — never the family name, never the refusal reason. This is the + grounded-rendering wrong=0 invariant in action.""" + spans = ( + SourceSpanLink(source_id="src", start=0, end=8, text="chickens"), + SourceSpanLink(source_id="src", start=10, end=17, text="rabbits"), + ) + attempt = ComprehensionAttempt( + organ="r2_constraints", + outcome="setup_refused", + refusal_reason="missing_total_count", + evidence=spans, + ) + a = assess_from_attempt(attempt) + assert a is not None + assert a.resolution_action == "ask_question" + assert a.grounded_terms == ("chickens", "rabbits") + # And missing_slots is still populated from the family. + assert len(a.missing_slots) == 1 + + +def test_residue_empty_for_non_ask_attempts(): + """A refusal that classifies to ``refuse_known_boundary`` carries no residue + even if it has evidence — only ask-mapped attempts get residue.""" + spans = (SourceSpanLink(source_id="src", start=0, end=5, text="dummy"),) + attempt = ComprehensionAttempt( + organ="r4_combined_rate", + outcome="setup_refused", + refusal_reason="cmb_non_positive_net_rate", + evidence=spans, + ) + a = assess_from_attempt(attempt) + assert a is not None + assert a.resolution_action == "refuse_known_boundary" + assert a.missing_slots == () + assert a.grounded_terms == () + + +def test_residue_never_contains_ungrounded_terms(): + """The hard wrong=0 invariant: nothing in ``grounded_terms`` may be invented + from the family/reason. Across the registry, an attempt with no evidence yields + no grounded terms, period.""" + for family in REGISTRY: + if not family.refusal_reasons: + continue + reason = family.refusal_reasons[0] + attempt = ComprehensionAttempt( + organ="r2_constraints", + outcome="setup_refused", + refusal_reason=reason, + ) + a = assess_from_attempt(attempt) + if a is None: + continue + # Empty evidence ⇒ empty grounded_terms, full stop. + assert a.grounded_terms == (), (family.name, reason) + # Slot identifiers are structural — they may not appear verbatim in the + # source text, so they never count as "grounded terms" here. + for slot in a.missing_slots: + assert slot.slot_name not in a.grounded_terms + assert slot.binding_target not in a.grounded_terms + + +def test_missing_slot_keys_are_subset_of_ask_families(): + """``_FAMILY_TO_MISSING_SLOTS`` may only list ask-mapped families — slots are an + ask-only artifact. Non-ask families with slots would be a category error.""" + for name in _FAMILY_TO_MISSING_SLOTS: + family = family_by_name(name) + assert family is not None, name + kind, action = _FAMILY_TO_LIMITATION[name] + assert action == "ask_question", (name, action) # --- the epistemic-state axis -------------------------------------------------------- # @@ -210,3 +434,29 @@ def test_assess_attempt_unclassified_reason_refuses_never_asks(): assert a is not None assert a.resolution_action == "refuse_known_boundary" assert a.resolution_action != "ask_question" + + +# --- off-serving AST guard ----------------------------------------------------------- # + +def test_limitation_module_is_off_serving(): + """The limitation module must import nothing from ``generate.derivation`` or + ``core.reliability_gate`` — that is the discipline that lets the disclosure spine + move without touching the sealed GSM8K serving metric. Enforced AST-side so a + regression is caught at module-load time, not by metric drift.""" + forbidden = ("generate.derivation", "core.reliability_gate") + path = ( + Path(__file__).resolve().parent.parent + / "core" / "epistemic_disclosure" / "limitation.py" + ) + tree = ast.parse(path.read_text()) + seen: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + seen.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module is not None: + seen.append(node.module) + for module in seen: + for forbidden_prefix in forbidden: + assert not (module == forbidden_prefix or module.startswith(forbidden_prefix + ".")), ( + f"off-serving breach: limitation.py imports {module!r}" + )