feat(kernel): introduce construction-affordance catalog skeleton
This commit is contained in:
parent
0a168bfb99
commit
7b8c844b05
7 changed files with 853 additions and 2 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
36
docs/analysis/pr836-lookback-2026-06-20.md
Normal file
36
docs/analysis/pr836-lookback-2026-06-20.md
Normal file
|
|
@ -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`.
|
||||
458
generate/construction_affordances.py
Normal file
458
generate/construction_affordances.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
141
tests/test_construction_affordances.py
Normal file
141
tests/test_construction_affordances.py
Normal file
|
|
@ -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=(),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue