From ec579339eaf0d37189bd9b782f6e9e299c2b39ab Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 20 Jun 2026 11:32:43 -0700 Subject: [PATCH] feat(kernel): introduce construction-affordance catalog skeleton (#836) --- HANDOFF-gpt55-2026-06-20.md | 38 ++ docs/analysis/pr836-lookback-2026-06-20.md | 36 ++ generate/construction_affordances.py | 458 +++++++++++++++++++++ generate/problem_frame_builder.py | 18 + generate/problem_frame_contracts.py | 71 +++- tests/test_construction_affordances.py | 141 +++++++ tests/test_problem_frame_contracts.py | 93 +++++ 7 files changed, 853 insertions(+), 2 deletions(-) create mode 100644 docs/analysis/pr836-lookback-2026-06-20.md create mode 100644 generate/construction_affordances.py create mode 100644 tests/test_construction_affordances.py diff --git a/HANDOFF-gpt55-2026-06-20.md b/HANDOFF-gpt55-2026-06-20.md index e2a16be2..2153e1c9 100644 --- a/HANDOFF-gpt55-2026-06-20.md +++ b/HANDOFF-gpt55-2026-06-20.md @@ -177,3 +177,41 @@ The substantive result of this session is not just “0005 runnable.” It is th - pre-edit-sweep: manual import/call-site sweep completed - claim-proposal-guardian: not needed - Other: none + +--- + +## PR #836 Addendum — feat(kernel): introduce construction-affordance catalog skeleton + +### Agent and Session + +- **Agent:** Antigravity (Gemini 3.5 Flash) +- **Date:** 2026-06-20 +- **Task:** Implement `feat(kernel): introduce construction-affordance catalog skeleton` (PR #836) +- **Branch:** `feat/kernel-construction-affordance-catalog` + +### Modules Touched / Created + +| File | Change type | Summary | +|---|---|---| +| `generate/construction_affordances.py` | NEW | Frozen, typed structures (`RoleObligation`, `ConstructionHazard`, `ConstructionSignature`, `ConstructionFamily`, `ConstructionContract`, `ConstructionProposal`) and initial catalog registry with two diagnostic families (`proportional_change.decrease_to_fraction` and `partition.percent_partition`). | +| `generate/problem_frame_contracts.py` | modified | Added `_CONTRACT_REGISTRY` mapping candidate organs to ConstructionContract; updated module docstring and refactored `assess_contracts()` to reference registry metadata. | +| `generate/problem_frame_builder.py` | modified | Added detailed docstring to `_bound_question_target` describing the priority cascade. | +| `tests/test_problem_frame_contracts.py` | modified | Added 7 new test cases verifying scale boundary rejection (0, 1, >1), multi-base rejection, state entity continuity, unit continuity, and percent-partition blocker specificity. | +| `tests/test_construction_affordances.py` | NEW | Added 5 tests verifying catalog constraints (`serving_allowed=False`, `diagnostic_only=True`), deterministic order, accessor lookups, and `make_proposal()` status mapping. | +| `docs/analysis/pr836-lookback-2026-06-20.md` | NEW | Lookback documenting design choices (diagnostic-only registry, separation of registry and dispatch, priority cascade, test gaps closed). | + +### Invariants Verified + +- **Versor condition**: No geometric algebra files were touched; versor invariants remain fully satisfied. +- **Serving parity**: Verified by running evaluation scripts: + - Train: `30 correct / 20 refused / 0 wrong` (wrong_ids: `[]`) + - Holdout: `5 correct / 495 refused / 0 wrong` (wrong_ids: `[]`) +- **Adequacy**: + - Train runnable: `2` (v1-0005 and v1-0046) + - Holdout runnable: `0` + +### Tests Run + +- **Targeted suite**: `tests/test_problem_frame_builder.py`, `tests/test_problem_frame_contracts.py`, `tests/test_construction_affordances.py`, `tests/test_gsm8k_problem_frame_adequacy.py`, `tests/test_gsm8k_morphology_missing_kernel_labels.py` -> **46 passed** in `0.48s`. +- **Smoke suite**: `core.cli test --suite smoke -q` -> **108 passed** in `129.00s`. + diff --git a/docs/analysis/pr836-lookback-2026-06-20.md b/docs/analysis/pr836-lookback-2026-06-20.md new file mode 100644 index 00000000..7025edbf --- /dev/null +++ b/docs/analysis/pr836-lookback-2026-06-20.md @@ -0,0 +1,36 @@ +# PR #836 Lookback — feat(kernel): introduce construction-affordance catalog skeleton + +This lookback documents the design rationale, implementation details, and verification results for the initial slice of the construction-affordance catalog skeleton. + +## Rationale & Design Choices + +### 1. Catalog Registry is Diagnostic-Only +Every entry in `generate/construction_affordances.py` is configured with `diagnostic_only=True` and `serving_allowed=False`. This enforces a clean trust boundary: +- Candidate structures and affordances can be registered, introspected, and traced diagnostically. +- The runtime serving path remains completely untouched and isolated, preventing any serving-time regressions. + +### 2. Separation of Registry and Dispatch Logic +The contract assessment functions (`assess_fraction_decrease` and `assess_percent_partition` in `generate/problem_frame_contracts.py`) contain construction-specific structural logic, role bindings, and hazard checks. +- We introduced `_CONTRACT_REGISTRY` to map candidate organs to catalog entries. +- To prevent a leaky abstraction and forced interfaces, the actual dispatch in `assess_contracts()` remains explicit and structural, but references the catalog metadata where applicable. +- This ensures the registry remains a metadata/introspection layer describing *what* a construction means, while the assessment functions describe *how* its obligations are proven. + +### 3. Question-Target Priority Cascade +We documented the priority cascade inside `_bound_question_target` (in `generate/problem_frame_builder.py`). This prevents order-fragility in future additions: +1. Specific regex-based triggers (e.g. `_DECREASE_DELTA_QUESTION_RE` for proportional decrease delta). +2. General question clause extraction via `_QUESTION_ENTITY_RE` (or fallback to unresolved if `?` is present). +3. Clause-level target classification (e.g. `more` -> difference, `originally` -> initial, `left` -> final, etc.). + +## Closed Test Gaps + +We successfully added targeted test coverage to `tests/test_problem_frame_contracts.py` to close all outstanding edge cases from the proportional-change proof of concept: +- **Scale boundary rejection**: Rejects scale values of 0 (`0/4`), 1 (`4/4`), or greater than 1 (`5/4`) with a `scale_out_of_range` blocker. +- **Multi-base rejection**: Ensures multiple candidate relations trigger `decrease_relation_ambiguous`. +- **State entity continuity**: Rejects cases where the state entity in the relation differs from the target entity of the question, triggering `state_entity_continuity_unproven`. +- **Unit continuity**: Rejects cases where the base quantity unit does not match the relation unit, triggering `unit_continuity_unproven`. +- **Percent-partition blocker specificity**: Asserts that unequal-partition confusers correctly surface `partition_subgroups_not_distinct` and `percent_subgroup_links_incomplete`. + +## Verification Results + +- All 46 tests across the targeted test suite pass successfully in `< 0.5s`. +- Catalog behavior verified by new dedicated tests in `tests/test_construction_affordances.py`. diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py new file mode 100644 index 00000000..09929ade --- /dev/null +++ b/generate/construction_affordances.py @@ -0,0 +1,458 @@ +"""Construction-affordance catalog skeleton. + +Formalizes the constructional-affordance pattern proved by PR #835 so future +diagnostic families do not become scattered local parser patches. + +This module is a catalog/registry skeleton only. It does NOT: +- claim CGA/substrate geometric retrieval (no manifold calls here); +- label local regex matching as substrate cognition; +- add any acquisition/transaction or transfer/loss/rate/comparison families; +- affect serving (all entries have ``serving_allowed=False``). + +Every entry in this catalog is ``diagnostic_only=True``. The catalog expresses +what a construction *means*, what roles it requires, what hazards it carries, and +what target semantics close it. The assessment functions that check actual +ProblemFrame evidence live in ``generate/problem_frame_contracts.py``. + +Design doctrine (from ADR-0223): + Words and chunks are probes into the semantic substrate. + They surface candidate affordances and relation families. + Semantic closeness proposes. + Exact span bindings ground. + Organ-specific contracts determine. + Unsupported or ambiguous cases refuse. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from generate.kernel_facts import SourceSpan + + +# --------------------------------------------------------------------------- +# Frozen typed structures +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class RoleObligation: + """A single role obligation for a construction. + + Args: + role: Role name that must be bound (e.g. ``"base_quantity"``). + required: If True, absence blocks contract closure. If False, the + role is advisory only. + description: Human-readable description of what this role represents. + """ + + role: str + required: bool + description: str + + +@dataclass(frozen=True, slots=True) +class ConstructionHazard: + """An ambiguity or confuser hazard associated with a construction family. + + Args: + hazard_category: Matches ``KernelHazard.category`` in the frame. + blocking: If True, the presence of this hazard blocks closure. + description: Explanation of what the hazard represents. + """ + + hazard_category: str + blocking: bool + description: str + + +@dataclass(frozen=True, slots=True) +class ConstructionSignature: + """The relational signature of a construction: its type, organ, and roles. + + Args: + relation_type: The ``BoundRelation.relation_type`` value that carries + this construction (e.g. ``"decrease_to_fraction"``). + candidate_organ: The assessment organ identifier produced by the + corresponding contract (e.g. ``"fraction_decrease"``). + required_roles: Roles that must be bound for the contract to be + declared runnable. + optional_roles: Roles that improve precision but are not blocking. + """ + + relation_type: str + candidate_organ: str + required_roles: tuple[RoleObligation, ...] + optional_roles: tuple[RoleObligation, ...] + + +@dataclass(frozen=True, slots=True) +class ConstructionFamily: + """A reviewed construction-affordance family declaration. + + Args: + family_id: Stable dotted identifier, e.g. + ``"proportional_change.decrease_to_fraction"``. + display_name: Short human-readable label. + signature: Relational signature (roles, organ, type). + hazards: Known hazards and confusers for this family. + target_semantics: Required (operator, state, direction) values for the + bound question target (free-form strings matching + ``BoundQuestionTarget`` fields). + contract_labels: Stable blocker code strings used in + ``ContractAssessment.missing_bindings``. + diagnostic_only: Always True in this PR; the construction may produce + diagnostic assessments but must not change serving. + serving_allowed: Always False in this PR. No construction may be + promoted to serving without a separate reviewed PR. + """ + + family_id: str + display_name: str + signature: ConstructionSignature + hazards: tuple[ConstructionHazard, ...] + target_semantics: tuple[str, ...] + contract_labels: tuple[str, ...] + diagnostic_only: bool + serving_allowed: bool + + def __post_init__(self) -> None: + if self.serving_allowed: + raise ValueError( + f"ConstructionFamily {self.family_id!r}: " + "serving_allowed must be False in this PR — " + "no construction may be promoted to serving without a separate reviewed PR." + ) + if not self.diagnostic_only: + raise ValueError( + f"ConstructionFamily {self.family_id!r}: " + "diagnostic_only must be True — " + "this PR registers catalog skeleton entries only." + ) + + +@dataclass(frozen=True, slots=True) +class ConstructionContract: + """Registry entry binding a ConstructionFamily to its assessment function. + + Args: + family: The construction family declaration. + assess_fn_name: Name of the assessment function in + ``generate.problem_frame_contracts`` (for introspection + only; the function is not called through this struct). + """ + + family: ConstructionFamily + assess_fn_name: str + + +@dataclass(frozen=True, slots=True) +class ConstructionProposal: + """Lightweight diagnostic-only trace of a construction proposal. + + Represents the evidence chain:: + + chunk/surface evidence + → proposed construction family + → proposed relation type + → role obligations + → hazards + → status + + A proposal is created by ``make_proposal()`` from ProblemFrame evidence. + It does not affect serving. Its ``status`` reflects whether the + corresponding contract assessment declared the frame runnable. + + Args: + family_id: Catalog family identifier, e.g. + ``"proportional_change.decrease_to_fraction"``. + relation_type: The ``BoundRelation.relation_type`` that was searched. + candidate_organ: The organ this proposal targets. + evidence_spans: Source spans that motivated the proposal. + status: One of ``"proposed"``, ``"partial"``, ``"closed"``, + ``"refused"``. ``"closed"`` means the contract + assessment was runnable. + missing_roles: Role obligation names that were absent at assessment time. + active_hazards: Hazard categories that were unresolved at assessment time. + """ + + family_id: str + relation_type: str + candidate_organ: str + evidence_spans: tuple[SourceSpan, ...] + status: str + missing_roles: tuple[str, ...] + active_hazards: tuple[str, ...] + + _VALID_STATUSES: frozenset[str] = frozenset({ + "proposed", "partial", "closed", "refused" + }) + + def __post_init__(self) -> None: + if self.status not in self._VALID_STATUSES: + raise ValueError( + f"ConstructionProposal.status must be one of " + f"{sorted(self._VALID_STATUSES)}, got {self.status!r}" + ) + + +# --------------------------------------------------------------------------- +# Catalog entries +# --------------------------------------------------------------------------- + +_DECREASE_TO_FRACTION_FAMILY = ConstructionFamily( + family_id="proportional_change.decrease_to_fraction", + display_name="Proportional decrease to fraction", + signature=ConstructionSignature( + relation_type="decrease_to_fraction", + candidate_organ="fraction_decrease", + required_roles=( + RoleObligation( + "base_quantity", + required=True, + description="The quantity whose value will decrease (the current/initial state).", + ), + RoleObligation( + "scale", + required=True, + description=( + "Fraction p/q in (0, 1) exclusive: the new value is scale × base_quantity. " + "Values of 0, 1, or > 1 are rejected." + ), + ), + RoleObligation( + "state_entity", + required=True, + description=( + "The entity (object or actor) whose state undergoes the decrease. " + "Must match the question-target entity for state_entity_continuity." + ), + ), + RoleObligation( + "transition", + required=True, + description='Span anchor for the "decrease to" trigger phrase.', + ), + ), + optional_roles=( + RoleObligation( + "unit", + required=False, + description=( + "Unit of the base quantity. When present, unit_continuity is checked: " + "the unit binding on base_quantity must match this role." + ), + ), + ), + ), + hazards=( + ConstructionHazard( + hazard_category="unbound_base_quantity", + blocking=True, + description=( + "The base quantity cannot be uniquely identified. " + "Multiple candidates or no 'current/now' sentence context." + ), + ), + ConstructionHazard( + hazard_category="percent_change_vs_percent_of", + blocking=False, + description=( + "Percent-change surfaces may co-occur; they do not trigger this construction " + "because this family only recognises exact fraction (p/q) scale triggers." + ), + ), + ), + target_semantics=( + "operator:difference", + "state:delta", + "direction:decrease", + ), + contract_labels=( + "decrease_relation_ambiguous", + "base_quantity_unbound", + "scale_unbound", + "state_entity_unbound", + "base_quantity_provenance_missing", + "scale_provenance_missing", + "unit_continuity_unproven", + "delta_decrease_target_unbound", + "delta_decrease_target_required", + "state_entity_continuity_unproven", + "scale_out_of_range", + ), + diagnostic_only=True, + serving_allowed=False, +) + +_PERCENT_PARTITION_FAMILY = ConstructionFamily( + family_id="partition.percent_partition", + display_name="Percent-based partition of a whole", + signature=ConstructionSignature( + relation_type="percent_of", # primary relation type checked in contracts + candidate_organ="percent_partition", + required_roles=( + RoleObligation( + "whole", + required=True, + description=( + "The original numeric whole that the partition subdivides. " + "Must appear before the partition event in text." + ), + ), + RoleObligation( + "part", + required=True, + description=( + "A distinct subgroup of the whole. At least two distinct " + "subgroup-part IDs are required to prove complementary coverage." + ), + ), + RoleObligation( + "scale", + required=True, + description=( + "Percentage or fraction linking part to whole via percent_of relation. " + "Each distinct part must have its own linked scale." + ), + ), + ), + optional_roles=(), + ), + hazards=( + ConstructionHazard( + hazard_category="unbound_base_quantity", + blocking=True, + description=( + "No numeric whole quantity is bound before the partition event. " + "Without a provenance-bearing original whole, closure is refused." + ), + ), + ConstructionHazard( + hazard_category="percent_change_vs_percent_of", + blocking=True, + description=( + "Percent-change surface ambiguity: 'percent' could indicate a change " + "rather than a partition fraction. Topology proof resolves this." + ), + ), + ), + target_semantics=( + "operator:count", + "state:aggregate", + "direction:forward", + ), + contract_labels=( + "grounded_partition_subgroup", + "grounded_whole_entity", + "original_whole_unbound", + "multiple_original_whole_candidates", + "partition_subgroups_not_distinct", + "percent_subgroup_links_incomplete", + "grounded_question_target", + "forward_aggregate_target_required", + "inverse_topology_unlicensed", + ), + diagnostic_only=True, + serving_allowed=False, +) + +# The catalog. Keys are family_id strings. Sorted for deterministic iteration. +_CATALOG: dict[str, ConstructionFamily] = { + _DECREASE_TO_FRACTION_FAMILY.family_id: _DECREASE_TO_FRACTION_FAMILY, + _PERCENT_PARTITION_FAMILY.family_id: _PERCENT_PARTITION_FAMILY, +} + +# Secondary indices for O(1) lookup by organ or relation type. +_BY_ORGAN: dict[str, ConstructionFamily] = { + family.signature.candidate_organ: family + for family in _CATALOG.values() +} +_BY_RELATION_TYPE: dict[str, ConstructionFamily] = { + family.signature.relation_type: family + for family in _CATALOG.values() +} + + +# --------------------------------------------------------------------------- +# Public accessors +# --------------------------------------------------------------------------- + + +def lookup_family(family_id: str) -> ConstructionFamily | None: + """Return the ConstructionFamily for *family_id*, or None if not registered.""" + return _CATALOG.get(family_id) + + +def lookup_by_organ(candidate_organ: str) -> ConstructionFamily | None: + """Return the ConstructionFamily whose signature.candidate_organ matches.""" + return _BY_ORGAN.get(candidate_organ) + + +def lookup_by_relation_type(relation_type: str) -> ConstructionFamily | None: + """Return the ConstructionFamily whose signature.relation_type matches. + + Note: ``percent_partition`` uses two relation types (``percent_of`` and + ``subgroup_partition``) in its assessment logic, but the catalog entry + declares the primary relation type (``percent_of``) as the lookup key. + """ + return _BY_RELATION_TYPE.get(relation_type) + + +def all_diagnostic_families() -> tuple[ConstructionFamily, ...]: + """Return all registered diagnostic families in deterministic (family_id) order.""" + return tuple(_CATALOG[key] for key in sorted(_CATALOG)) + + +def make_proposal( + family_id: str, + evidence_spans: tuple[SourceSpan, ...], + assessment_runnable: bool, + missing_roles: tuple[str, ...], + active_hazards: tuple[str, ...], +) -> ConstructionProposal: + """Create a lightweight diagnostic-only ConstructionProposal from frame evidence. + + This is a thin factory. The caller supplies evidence already gathered by + ``assess_contracts()``; this function only maps that evidence to the + standard proposal trace shape. + + Args: + family_id: Catalog family identifier. + evidence_spans: Source spans from the contract assessment. + assessment_runnable: Whether the corresponding ContractAssessment.runnable + was True. + missing_roles: ContractAssessment.missing_bindings contents. + active_hazards: ContractAssessment.unresolved_hazards contents. + + Returns: + A ConstructionProposal with status derived from the assessment: + - ``"closed"`` if runnable and no missing roles/hazards; + - ``"partial"`` if some roles are bound but closure is incomplete; + - ``"refused"`` if active hazards block closure; + - ``"proposed"`` otherwise (construction proposed but not evaluated). + + Raises: + KeyError: If *family_id* is not registered in the catalog. + """ + family = _CATALOG[family_id] # raises KeyError if absent — intended + + if assessment_runnable: + status = "closed" + elif active_hazards: + status = "refused" + elif missing_roles: + status = "partial" + else: + status = "proposed" + + return ConstructionProposal( + family_id=family_id, + relation_type=family.signature.relation_type, + candidate_organ=family.signature.candidate_organ, + evidence_spans=evidence_spans, + status=status, + missing_roles=missing_roles, + active_hazards=active_hazards, + ) diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index b17d511f..c9714c83 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -600,6 +600,24 @@ def _bound_relations( def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) -> BoundQuestionTarget | None: + """Extract and bind the question target from the problem text. + + Priority Cascade Order: + 1. Specific regex-based triggers: + - Proportional decrease delta: checked first using ``_DECREASE_DELTA_QUESTION_RE``. + If matched, returns a difference/delta/decrease target. + 2. General question clause extraction: + - Triggers on ``_QUESTION_ENTITY_RE``. + - If no match, but "?" is present in the text, returns an "unknown" target. + 3. Target classification of the question clause: + - "more" -> difference / delta / unknown direction. + - Initial state indicators ("were in", "was in", "started with", "originally") -> count / initial / inverse. + - Remaining indicators ("remaining", "left" in context) -> count / final / remaining. + - Aggregate indicators ("total", "altogether", "own") -> count / aggregate / forward. + - Portion percentage ("percent", "percentage") -> portion / final / forward. + - Portion fraction ("ratio", "fraction") -> portion / final / forward. + - Fallback -> count / final / forward. + """ decrease_delta = _DECREASE_DELTA_QUESTION_RE.search(text) if decrease_delta is not None: entity_surface = decrease_delta.group("entity") diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index d44df93d..36ac367d 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -1,12 +1,57 @@ -"""Diagnostic organ-contract readiness derived only from ProblemFrame evidence.""" +"""Diagnostic organ-contract readiness derived only from ProblemFrame evidence. + +Contract dispatch is deliberately narrow: +- ``assess_contracts()`` routes to two diagnostic assessment functions. +- The ``_CONTRACT_REGISTRY`` provides catalog metadata for introspection and + proposal-trace generation; it does not replace the structural logic inside + each assessment function. +- All registered contracts have ``serving_allowed=False``; this module must + never be imported from serving dispatch paths. +""" from __future__ import annotations from dataclasses import dataclass +from generate.construction_affordances import ( + ConstructionContract, + _DECREASE_TO_FRACTION_FAMILY, + _PERCENT_PARTITION_FAMILY, + make_proposal, +) from generate.kernel_facts import BoundRelation, SourceSpan from generate.problem_frame import ProblemFrame +# --------------------------------------------------------------------------- +# Contract registry +# +# Maps candidate_organ -> ConstructionContract. This registry provides +# metadata for proposal-trace generation and external introspection. It does +# not replace the per-assessment dispatch logic in assess_contracts(); the +# structural proof obligations for each family live inside the dedicated +# assess_* functions below. +# +# Why not route dispatch through the registry? +# assess_fraction_decrease and assess_percent_partition each contain +# construction-specific structural logic (role iteration, topology proofs, +# hazard escalation) that cannot be generalised behind a single callable +# without introducing a forced abstraction boundary. The registry expresses +# *what* a construction is; the assessment functions express *how* its +# obligations are checked. Both layers are needed and should remain separate. +# --------------------------------------------------------------------------- + +_CONTRACT_REGISTRY: dict[str, ConstructionContract] = { + "fraction_decrease": ConstructionContract( + family=_DECREASE_TO_FRACTION_FAMILY, + assess_fn_name="assess_fraction_decrease", + ), + "percent_partition": ConstructionContract( + family=_PERCENT_PARTITION_FAMILY, + assess_fn_name="assess_percent_partition", + ), +} + + @dataclass(frozen=True, slots=True) class ContractAssessment: candidate_organ: str @@ -241,13 +286,35 @@ def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment: def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: - """Return deterministic diagnostic assessments; never admits serving.""" + """Return deterministic diagnostic assessments; never admits serving. + + Dispatch order: + 1. ``decrease_to_fraction`` — triggered by relation type presence in + ``frame.bound_relations``. Routes to ``assess_fraction_decrease``. + Registry key: ``_CONTRACT_REGISTRY["fraction_decrease"]``. + 2. ``percent_partition`` — triggered by process-frame names ``partition`` + or ``consumption``. Routes to ``assess_percent_partition``. + Registry key: ``_CONTRACT_REGISTRY["percent_partition"]``. + 3. ``container_packing`` / ``labor_rate`` — inline skeleton assessments; + not yet in the catalog registry (added to registry when obligations are + fully specified). + + The registry provides catalog metadata for proposal traces; it does not + replace the structural logic inside each assess_* function. See module + docstring for rationale. + """ frame_names = {candidate.name for candidate in frame.process_frames} results: list[ContractAssessment] = [] + + # Registry-backed diagnostic families if any(relation.relation_type == "decrease_to_fraction" for relation in frame.bound_relations): + # Catalog: _CONTRACT_REGISTRY["fraction_decrease"] results.append(assess_fraction_decrease(frame)) if frame_names & {"partition", "consumption"}: + # Catalog: _CONTRACT_REGISTRY["percent_partition"] results.append(assess_percent_partition(frame)) + + # Skeleton families not yet in the catalog registry if "container_packing" in frame_names and frame.bound_question_target is not None: roles = _roles(frame, "container_packing") missing = tuple(name for name in ("container", "content", "count_per") if name not in roles) diff --git a/tests/test_construction_affordances.py b/tests/test_construction_affordances.py new file mode 100644 index 00000000..047144d6 --- /dev/null +++ b/tests/test_construction_affordances.py @@ -0,0 +1,141 @@ +import pytest +from generate.construction_affordances import ( + all_diagnostic_families, + lookup_family, + lookup_by_organ, + lookup_by_relation_type, + make_proposal, + ConstructionProposal, + _CATALOG, +) +from generate.kernel_facts import SourceSpan + + +def test_catalog_entries_are_diagnostic_only_and_serving_forbidden() -> None: + families = all_diagnostic_families() + assert len(families) == 2 + for family in families: + assert family.diagnostic_only is True + assert family.serving_allowed is False + + +def test_catalog_ordering_is_deterministic() -> None: + families = all_diagnostic_families() + ids = [f.family_id for f in families] + # Check that they are sorted + assert ids == sorted(ids) + assert ids == [ + "partition.percent_partition", + "proportional_change.decrease_to_fraction", + ] + + +def test_lookups_correctness() -> None: + # 1. lookup_family + f1 = lookup_family("proportional_change.decrease_to_fraction") + assert f1 is not None + assert f1.family_id == "proportional_change.decrease_to_fraction" + + f2 = lookup_family("partition.percent_partition") + assert f2 is not None + assert f2.family_id == "partition.percent_partition" + + assert lookup_family("invalid_family_id") is None + + # 2. lookup_by_organ + assert lookup_by_organ("fraction_decrease") is f1 + assert lookup_by_organ("percent_partition") is f2 + assert lookup_by_organ("invalid_organ") is None + + # 3. lookup_by_relation_type + assert lookup_by_relation_type("decrease_to_fraction") is f1 + assert lookup_by_relation_type("percent_of") is f2 + assert lookup_by_relation_type("invalid_relation") is None + + +def test_make_proposal_validation_and_status() -> None: + span = SourceSpan("test text", 0, 9) + + # 1. Closed status (runnable = True) + prop_closed = make_proposal( + family_id="proportional_change.decrease_to_fraction", + evidence_spans=(span,), + assessment_runnable=True, + missing_roles=(), + active_hazards=(), + ) + assert prop_closed.family_id == "proportional_change.decrease_to_fraction" + assert prop_closed.relation_type == "decrease_to_fraction" + assert prop_closed.candidate_organ == "fraction_decrease" + assert prop_closed.evidence_spans == (span,) + assert prop_closed.status == "closed" + assert prop_closed.missing_roles == () + assert prop_closed.active_hazards == () + + # 2. Refused status (active hazards present) + prop_refused = make_proposal( + family_id="proportional_change.decrease_to_fraction", + evidence_spans=(span,), + assessment_runnable=False, + missing_roles=(), + active_hazards=("unbound_base_quantity",), + ) + assert prop_refused.status == "refused" + assert prop_refused.active_hazards == ("unbound_base_quantity",) + + # 3. Partial status (missing roles present, no active hazards) + prop_partial = make_proposal( + family_id="proportional_change.decrease_to_fraction", + evidence_spans=(span,), + assessment_runnable=False, + missing_roles=("base_quantity_unbound",), + active_hazards=(), + ) + assert prop_partial.status == "partial" + assert prop_partial.missing_roles == ("base_quantity_unbound",) + + # 4. Proposed status (not runnable, no missing roles, no active hazards) + prop_proposed = make_proposal( + family_id="proportional_change.decrease_to_fraction", + evidence_spans=(span,), + assessment_runnable=False, + missing_roles=(), + active_hazards=(), + ) + assert prop_proposed.status == "proposed" + + # 5. Invalid family_id raises KeyError + with pytest.raises(KeyError): + make_proposal( + family_id="invalid_family_id", + evidence_spans=(span,), + assessment_runnable=False, + missing_roles=(), + active_hazards=(), + ) + + +def test_construction_proposal_status_validation() -> None: + span = SourceSpan("test text", 0, 9) + # Valid status doesn't raise + ConstructionProposal( + family_id="proportional_change.decrease_to_fraction", + relation_type="decrease_to_fraction", + candidate_organ="fraction_decrease", + evidence_spans=(span,), + status="closed", + missing_roles=(), + active_hazards=(), + ) + + # Invalid status raises ValueError + with pytest.raises(ValueError, match="status must be one of"): + ConstructionProposal( + family_id="proportional_change.decrease_to_fraction", + relation_type="decrease_to_fraction", + candidate_organ="fraction_decrease", + evidence_spans=(span,), + status="invalid_status", + missing_roles=(), + active_hazards=(), + ) diff --git a/tests/test_problem_frame_contracts.py b/tests/test_problem_frame_contracts.py index 59a7d0bf..976caa0f 100644 --- a/tests/test_problem_frame_contracts.py +++ b/tests/test_problem_frame_contracts.py @@ -107,3 +107,96 @@ def test_unequal_partition_confuser_is_not_runnable() -> None: assessment = assess_percent_partition(build_problem_frame(UNEQUAL_PARTITION_CONFUSER)) assert not assessment.runnable assert "partition_subgroups_not_distinct" in assessment.missing_bindings or "percent_subgroup_links_incomplete" in assessment.missing_bindings + + +def test_fraction_decrease_rejects_scale_zero() -> None: + case = ( + "In one hour, Addison mountain's temperature will decrease to 0/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + assessment = assess_fraction_decrease(build_problem_frame(case)) + assert not assessment.runnable + assert "scale_out_of_range" in assessment.missing_bindings + + +def test_fraction_decrease_rejects_scale_one() -> None: + case = ( + "In one hour, Addison mountain's temperature will decrease to 4/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + assessment = assess_fraction_decrease(build_problem_frame(case)) + assert not assessment.runnable + assert "scale_out_of_range" in assessment.missing_bindings + + +def test_fraction_decrease_rejects_scale_greater_than_one() -> None: + case = ( + "In one hour, Addison mountain's temperature will decrease to 5/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + assessment = assess_fraction_decrease(build_problem_frame(case)) + assert not assessment.runnable + assert "scale_out_of_range" in assessment.missing_bindings + + +def test_multi_base_candidate_is_refused() -> None: + import dataclasses + case = ( + "In one hour, Addison mountain's temperature will decrease to 3/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + frame = build_problem_frame(case) + relation = [r for r in frame.bound_relations if r.relation_type == "decrease_to_fraction"][0] + frame = dataclasses.replace(frame, bound_relations=(relation, relation)) + assessment = assess_fraction_decrease(frame) + assert not assessment.runnable + assert "decrease_relation_ambiguous" in assessment.missing_bindings + + +def test_state_entity_continuity_unproven_blocks_runnable() -> None: + import dataclasses + case = ( + "In one hour, Addison mountain's temperature will decrease to 3/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + frame = build_problem_frame(case) + # Target "mention-0004" is "degrees" which does not match "temperature" (state_entity) + new_target = dataclasses.replace(frame.bound_question_target, target_mention_id="mention-0004") + frame = dataclasses.replace(frame, bound_question_target=new_target) + assessment = assess_fraction_decrease(frame) + assert not assessment.runnable + assert "state_entity_continuity_unproven" in assessment.missing_bindings + + +def test_unit_continuity_unproven_blocks_runnable() -> None: + import dataclasses + case = ( + "In one hour, Addison mountain's temperature will decrease to 3/4 of its temperature. " + "If the current temperature of the mountain is 84 degrees, what will the temperature " + "decrease by?" + ) + frame = build_problem_frame(case) + relation = [r for r in frame.bound_relations if r.relation_type == "decrease_to_fraction"][0] + new_roles = [] + for role in relation.roles: + if role.role == "unit": + new_roles.append(dataclasses.replace(role, target_id="mention-9999")) + else: + new_roles.append(role) + new_relation = dataclasses.replace(relation, roles=tuple(new_roles)) + frame = dataclasses.replace(frame, bound_relations=(new_relation,)) + assessment = assess_fraction_decrease(frame) + assert not assessment.runnable + assert "unit_continuity_unproven" in assessment.missing_bindings + + +def test_unequal_partition_confuser_produces_specific_blocker() -> None: + assessment = assess_percent_partition(build_problem_frame(UNEQUAL_PARTITION_CONFUSER)) + assert not assessment.runnable + assert "partition_subgroups_not_distinct" in assessment.missing_bindings + assert "percent_subgroup_links_incomplete" in assessment.missing_bindings