From fbccb2a298342939bc185cdb3ee9a7c2cce518d8 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 20 Jun 2026 11:52:02 -0700 Subject: [PATCH] feat(kernel): route proportional-decrease through construction proposals (#837) * feat(kernel): route proportional-decrease through construction proposals * docs: clarify PR #837 scope in lookback --- HANDOFF-gpt55-2026-06-20.md | 38 +++++++++ docs/analysis/pr837-lookback-2026-06-20.md | 42 ++++++++++ generate/problem_frame.py | 2 + generate/problem_frame_builder.py | 23 +++++- generate/problem_frame_contracts.py | 7 ++ tests/test_proportional_decrease_proposal.py | 87 ++++++++++++++++++++ 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 docs/analysis/pr837-lookback-2026-06-20.md create mode 100644 tests/test_proportional_decrease_proposal.py diff --git a/HANDOFF-gpt55-2026-06-20.md b/HANDOFF-gpt55-2026-06-20.md index 2153e1c9..746bc4b2 100644 --- a/HANDOFF-gpt55-2026-06-20.md +++ b/HANDOFF-gpt55-2026-06-20.md @@ -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. + + diff --git a/docs/analysis/pr837-lookback-2026-06-20.md b/docs/analysis/pr837-lookback-2026-06-20.md new file mode 100644 index 00000000..b9b161db --- /dev/null +++ b/docs/analysis/pr837-lookback-2026-06-20.md @@ -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: `[]`) diff --git a/generate/problem_frame.py b/generate/problem_frame.py index 89ffb31a..55a86102 100644 --- a/generate/problem_frame.py +++ b/generate/problem_frame.py @@ -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: diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index c9714c83..2b2e38cb 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -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, ...]: diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index 36ac367d..d31c58fa 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -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 diff --git a/tests/test_proportional_decrease_proposal.py b/tests/test_proportional_decrease_proposal.py new file mode 100644 index 00000000..38da570a --- /dev/null +++ b/tests/test_proportional_decrease_proposal.py @@ -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