feat(kernel): route proportional-decrease through construction proposals (#837)

* feat(kernel): route proportional-decrease through construction proposals

* docs: clarify PR #837 scope in lookback
This commit is contained in:
Shay 2026-06-20 11:52:02 -07:00 committed by GitHub
parent ec579339ea
commit fbccb2a298
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 198 additions and 1 deletions

View file

@ -215,3 +215,41 @@ The substantive result of this session is not just “0005 runnable.” It is th
- **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`.
---
## PR #837 Addendum — feat(kernel): route proportional-decrease through construction proposals
### Agent and Session
- **Agent:** Antigravity (Gemini 3.5 Flash)
- **Date:** 2026-06-20
- **Task:** Implement `feat(kernel): route proportional-decrease through construction proposals` (PR #837)
- **Branch:** `feat/kernel-decrease-construction-proposal-trace`
### Modules Touched / Created
| File | Change type | Summary |
|---|---|---|
| `generate/problem_frame.py` | modified | Added `proposals` field (`tuple[ConstructionProposal, ...]`) to the `ProblemFrame` dataclass, importing `ConstructionProposal` under `TYPE_CHECKING`. |
| `generate/problem_frame_builder.py` | modified | Updated `build_problem_frame` to perform a two-pass assembly: running contract assessments on the initial frame, mapping them to proposals using `make_proposal`, and returning the frame with `proposals` attached. |
| `generate/problem_frame_contracts.py` | modified | Added `get_contract_family_id(candidate_organ: str) -> str | None` to publicly map candidate organs to catalog family IDs without exposing the private contract registry. |
| `tests/test_proportional_decrease_proposal.py` | NEW | Added 6 new test cases verifying proposal trace existence, evidence spans, catalog backing, diagnostic-only constraints, blocked final-value behavior, and confuser exclusion. |
| `docs/analysis/pr837-lookback-2026-06-20.md` | NEW | Lookback documenting PR #837 design rationale, implementation details, and verification results. |
### Invariants Verified
- **Versor condition**: No geometric algebra files were touched; versor invariants remain fully satisfied.
- **Serving parity**:
- 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_proportional_decrease_proposal.py`, `tests/test_construction_affordances.py`, `tests/test_problem_frame_builder.py`, `tests/test_problem_frame_contracts.py`, `tests/test_gsm8k_problem_frame_adequacy.py`, `tests/test_gsm8k_morphology_missing_kernel_labels.py`, `tests/test_kernel_no_new_legacy_derivation_surfaces.py` -> **54 passed** in `0.66s`.
- **Smoke suite**: `core.cli test --suite smoke -q` -> Running in background.

View file

@ -0,0 +1,42 @@
# PR #837 Lookback — feat(kernel): route proportional-decrease through construction proposals
This lookback documents the design rationale, implementation details, and verification results for routing the proportional-decrease diagnostic path through typed `ConstructionProposal` objects.
## Rationale & Design Choices
### 1. Attaching `proposals` to `ProblemFrame`
We introduced the `proposals` field (`tuple[ConstructionProposal, ...]`) directly on the `ProblemFrame` class. This allows the frame representation to carry its candidate construction traces, linking surface evidence to the construction-affordance catalog in a self-contained manner.
### 2. Integration in `build_problem_frame`
To avoid circular imports and keep `ProblemFrameBuilder` clean, the proposals are attached in `build_problem_frame()` via a two-pass assembly:
1. Build the initial `ProblemFrame` with basic attributes.
2. Call `assess_contracts()` on the initial frame to retrieve `ContractAssessment` objects.
3. For each assessment whose candidate organ maps to a catalog family (via `get_contract_family_id()`), construct a `ConstructionProposal` via `make_proposal()`.
4. Recreate the `ProblemFrame` with the proposals tuple attached using `dataclasses.replace()`.
### 3. Exposing Public Accessors
We added the public helper `get_contract_family_id(candidate_organ: str) -> str | None` in `generate/problem_frame_contracts.py`. This avoids exposing the private mapping dict `_CONTRACT_REGISTRY` to external modules or test suites.
### 4. Scope & Boundary Clarification
PR #837 attaches assessment-backed proposal traces to `ProblemFrame` as a diagnostic trace. It does *not* make construction proposals drive relation binding or contract assessment, which remain decoupled at this stage.
## Tests Added
We introduced a dedicated test file `tests/test_proportional_decrease_proposal.py` covering:
- **Proposal trace existence**: Verifies that a `proportional_change.decrease_to_fraction` proposal is generated for proportional-decrease cases.
- **Exact evidence spans**: Asserts that proposal evidence spans correctly slice the original problem text.
- **Catalog alignment**: Verifies the proposal family ID, relation type, and candidate organ align with the catalog definition using public accessors.
- **Diagnostic-only constraints**: Validates that the underlying family has `diagnostic_only=True` and `serving_allowed=False`.
- **Blocked case handling**: Asserts that `FINAL_VALUE_CONFUSER` (a final-value question instead of a decrease-delta question) still produces a proposal trace but has a blocked status (not `"closed"`) and identifies `delta_decrease_target_unbound`.
- **Confuser exclusion**: Verifies that `AFFINE_CONFUSER` produces no proportional-decrease proposal trace.
## Verification Results
- All 54 tests pass successfully.
- Parity of adequacy metrics verified:
- Train runnable contracts remain `2` (`0005`, `0046`).
- Holdout runnable contracts remain `0`.
- Serving counts verified:
- Train: `30 correct / 20 refused / 0 wrong` (wrong_ids: `[]`)
- Holdout: `5 correct / 495 refused / 0 wrong` (wrong_ids: `[]`)

View file

@ -25,6 +25,7 @@ if TYPE_CHECKING:
)
from language_packs.scalar_equivalence import ScalarCandidate
from generate.process_frames import ProcessFrame
from generate.construction_affordances import ConstructionProposal
@dataclass(frozen=True, slots=True)
@ -114,6 +115,7 @@ class ProblemFrame:
bindings: tuple[MentionBinding, ...] = ()
bound_relations: tuple[BoundRelation, ...] = ()
bound_question_target: BoundQuestionTarget | None = None
proposals: tuple[ConstructionProposal, ...] = ()
class ProblemFrameBuilder:

View file

@ -748,7 +748,28 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
if bound_target is not None:
builder.set_bound_question_target(bound_target)
return builder.build()
initial_frame = builder.build()
from generate.problem_frame_contracts import assess_contracts, get_contract_family_id
from generate.construction_affordances import make_proposal
assessments = assess_contracts(initial_frame)
proposals = []
for assessment in assessments:
family_id = get_contract_family_id(assessment.candidate_organ)
if family_id is not None:
proposal = make_proposal(
family_id=family_id,
evidence_spans=assessment.evidence_spans,
assessment_runnable=assessment.runnable,
missing_roles=assessment.missing_bindings,
active_hazards=assessment.unresolved_hazards,
)
proposals.append(proposal)
import dataclasses
return dataclasses.replace(initial_frame, proposals=tuple(proposals))
def recognized_scalar_surfaces(frame: ProblemFrame) -> tuple[str, ...]:

View file

@ -52,6 +52,13 @@ _CONTRACT_REGISTRY: dict[str, ConstructionContract] = {
}
def get_contract_family_id(candidate_organ: str) -> str | None:
"""Return the catalog family ID for a candidate organ, if registered."""
contract = _CONTRACT_REGISTRY.get(candidate_organ)
return contract.family.family_id if contract else None
@dataclass(frozen=True, slots=True)
class ContractAssessment:
candidate_organ: str

View file

@ -0,0 +1,87 @@
"""Tests for feat(kernel): route proportional-decrease through construction proposals."""
from __future__ import annotations
from generate.problem_frame_builder import build_problem_frame
from generate.construction_affordances import lookup_family
FRACTION_DECREASE_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?"
)
FINAL_VALUE_CONFUSER = (
"In one hour, the lake's temperature will decrease to 3/4 of its temperature. "
"If the current temperature of the lake is 80 degrees, what will the temperature be?"
)
AFFINE_CONFUSER = (
"Marion has 1/4 more than what Yun currently has, plus 7. How many paperclips does Marion have?"
)
def test_proposal_trace_exists_for_decrease_to_fraction() -> None:
frame = build_problem_frame(FRACTION_DECREASE_CASE)
proposals = [p for p in frame.proposals if p.family_id == "proportional_change.decrease_to_fraction"]
assert len(proposals) == 1
proposal = proposals[0]
assert proposal.relation_type == "decrease_to_fraction"
assert proposal.candidate_organ == "fraction_decrease"
assert proposal.status == "closed"
def test_proposal_trace_includes_exact_evidence_span() -> None:
frame = build_problem_frame(FRACTION_DECREASE_CASE)
proposal = next(p for p in frame.proposals if p.family_id == "proportional_change.decrease_to_fraction")
# Verify evidence spans slice the original text exactly
for span in proposal.evidence_spans:
assert FRACTION_DECREASE_CASE[span.start:span.end] == span.text
# Verify key tokens/spans are present in the evidence
evidence_texts = {span.text for span in proposal.evidence_spans}
assert "decrease to" in evidence_texts
assert "3/4" in evidence_texts
assert "84" in evidence_texts
assert "temperature" in evidence_texts
def test_proposal_family_is_catalog_backed() -> None:
frame = build_problem_frame(FRACTION_DECREASE_CASE)
proposal = next(p for p in frame.proposals if p.family_id == "proportional_change.decrease_to_fraction")
# Use public catalog accessor to look up the family
family = lookup_family(proposal.family_id)
assert family is not None
assert family.family_id == "proportional_change.decrease_to_fraction"
assert family.signature.relation_type == proposal.relation_type
assert family.signature.candidate_organ == proposal.candidate_organ
def test_proposal_is_diagnostic_only_serving_disallowed() -> None:
frame = build_problem_frame(FRACTION_DECREASE_CASE)
proposal = next(p for p in frame.proposals if p.family_id == "proportional_change.decrease_to_fraction")
family = lookup_family(proposal.family_id)
assert family is not None
assert family.diagnostic_only is True
assert family.serving_allowed is False
def test_blocked_final_value_proportional_case_produces_proposal_but_remains_blocked() -> None:
frame = build_problem_frame(FINAL_VALUE_CONFUSER)
proposals = [p for p in frame.proposals if p.family_id == "proportional_change.decrease_to_fraction"]
assert len(proposals) == 1
proposal = proposals[0]
# The final value question target is not a delta/decrease target, so contract is not runnable
assert proposal.status != "closed"
assert "delta_decrease_target_unbound" in proposal.missing_roles
def test_no_proposal_trace_for_affine_more_than_confuser() -> None:
frame = build_problem_frame(AFFINE_CONFUSER)
proposals = [p for p in frame.proposals if p.family_id == "proportional_change.decrease_to_fraction"]
assert len(proposals) == 0