diff --git a/HANDOFF-gpt55-2026-06-20.md b/HANDOFF-gpt55-2026-06-20.md index cb17159b..bc1ac31d 100644 --- a/HANDOFF-gpt55-2026-06-20.md +++ b/HANDOFF-gpt55-2026-06-20.md @@ -180,6 +180,121 @@ The substantive result of this session is not just “0005 runnable.” It is th --- +## PR #841 Addendum — feat(kernel): route proportional-decrease through proposal-first seam + +### Agent and Session + +- **Agent:** gpt55 +- **Date:** 2026-06-20 +- **Reasoning effort used:** high +- **Grok Build mode used:** Headless +- **Session entry point:** Migrate only `proportional_change.decrease_to_fraction` from assessment-backed proposal synthesis to the ADR-0223/0224 proposal-first construction seam without changing serving or derivation. +- **Branch:** `feat/kernel-proportional-decrease-proposal-first` +- **Base:** `origin/main` at `168c9f26ac3deb8037d4eb7bca77c33dc9d5ff62` + +### Smoke Suite + Bootstrap Status + +```text +uv run python -m core.cli test --suite smoke -q +108 passed in 123.41s (baseline) +108 passed in 123.05s (post-edit) +``` + +The GPT55.md startup checklist was completed manually. The Codex worktree was clean and `HEAD == origin/main` before edits. `/Users/kaizenpro/Projects/core` was not modified, stashed, reset, cleaned, or reconciled. + +### Modules Touched + +| File | Change type | Summary | +|---|---|---| +| `generate/construction_affordances.py` | modified | Added a pre-assessment catalog proposal factory; proposals now carry immutable role obligations and enforce diagnostic-only / serving-disallowed posture. Retained the assessment-backed adapter only for unmigrated catalog paths. | +| `generate/problem_frame.py` | modified | Let `ProblemFrameBuilder` accumulate proposals before relation binding. | +| `generate/problem_frame_builder.py` | modified | Produces the proportional-decrease proposal from the exact existing surface chunk before mention/relation binding and assessment; preserves assessment-backed traces for other families. | +| `generate/problem_frame_contracts.py` | modified | Routes fraction-decrease assessment from proposal metadata read-only while leaving `assess_fraction_decrease` obligations unchanged. | +| `tests/test_construction_affordances.py` | modified | Covers the pre-assessment factory, catalog role obligations, and serving posture. | +| `tests/test_proportional_decrease_proposal.py` | modified | Proves proposal-before-assessment order, exact span grounding, proposal-driven dispatch, unchanged contract refusal, and confuser blocking. | +| `HANDOFF-gpt55-2026-06-20.md` | modified | Added this PR #841 continuity record. | + +### Invariants Verified + +- **Field closure:** Preserved by construction. No algebra, field, propagation, normalization, vault, or runtime field-state code changed. +- **Exact recall:** Preserved. No recall path, cosine, ANN, HNSW, or stochastic retrieval was added. +- **Proposal/binding/contract separation:** The proposal begins as `status="proposed"`; only `ContractAssessment` determines runnable/refused state from bound relations and question-target evidence. +- **Catalog and serving gates:** Proposal role obligations and posture come from the existing catalog; `diagnostic_only=True` and `serving_allowed=False` are enforced on the proposal type. +- **Controlled migration:** Only proportional decrease is proposal-first. Percent partition retains its existing assessment-backed trace; no foundational family gained implementation authorization. +- **No universal IR / no live-CGA claim:** The patch reuses `ConstructionProposal`, `ProblemFrame`, `BoundRelation`, and `ContractAssessment`; it adds no new carrier and makes no substrate-retrieval claim. +- **Serving parity:** Train remained `30 correct / 20 refused / 0 wrong`; holdout remained `5 correct / 495 refused / 0 wrong`; both wrong-id lists were empty. +- **Diagnostic parity:** Train remained 2 runnable contracts (`0005`, `0046`); holdout remained 0 runnable contracts. + +### Subagent / Arena Reconciliation + +- Number of subagents spawned: 0 +- Each subagent independently verified versor closure: N/A +- Reconciliation: N/A + +### Tests Run + +```bash +# exit 0 — 55 passed +uv run python -m pytest -q \ + tests/test_construction_affordances.py \ + tests/test_foundational_families.py \ + tests/test_problem_frame_builder.py \ + tests/test_problem_frame_contracts.py \ + tests/test_proportional_decrease_proposal.py \ + tests/test_kernel_no_new_legacy_derivation_surfaces.py + +# exit 0 — 108 passed +uv run python -m core.cli test --suite smoke -q + +# exit 0 — serving parity +# train: {'correct': 30, 'wrong': 0, 'refused': 20}; wrong_ids: [] +# holdout: {'correct': 5, 'wrong': 0, 'refused': 495}; wrong_ids: [] + +# exit 0 — diagnostic parity +# train contract_runnable_count: 2; ids: 0005, 0046 +# holdout contract_runnable_count: 0 + +# exit 0 +uv run python -m compileall -q \ + generate/construction_affordances.py \ + generate/problem_frame.py \ + generate/problem_frame_builder.py \ + generate/problem_frame_contracts.py + +# exit 0 +git diff --check +``` + +### Open Tasks / Next Session Entry Point + +1. Review the six implementation/test files and publish PR #841 with the brief's required body sections. +2. Keep percent partition assessment-backed until a separately authorized proposal-first migration. + +### Known Hazards / Do Not Touch + +- Do not map `ConstructionProposal.status` to fraction assessment output again; that recreates the assessment-first inversion. +- Do not generalize the reused `decrease to of` recognizer into generic state change, acquisition, or transaction behavior. +- Do not connect foundational-family registry metadata to runtime authorization or serving. +- Do not edit derivation, candidate graph, eval cases, reports, packs, policy, identity, recall, field, or algebra for this slice. + +### Architectural Decision + +The proposal is the construction hypothesis, not a cached verdict. It carries the catalog's obligations and exact motivating chunk. `BoundRelation` grounds those obligations, and the existing `ContractAssessment` remains the corrective/conjugate stage that closes or refuses the hypothesis. Other families are deliberately left on the legacy assessment-backed adapter to keep this migration singular. + +### What Must Not Be Forgotten + +PR #841 proves the ordering `surface chunk → proposed construction → span-grounded relation → ContractAssessment` for proportional decrease only. A positive proposal is not serving authorization, is not a runnable verdict, and is not evidence that general CGA/substrate retrieval is live. + +### Skills Used + +- core-bootstrap: unavailable; checklist completed manually +- versor-coherence-guardian: unavailable; closure preserved by scope and smoke validation +- pre-edit-sweep: completed manually across imports, callers, tests, evals, calibration, and scripts +- claim-proposal-guardian: unavailable; proposal posture and assessment separation verified directly +- Other: none + +--- + ## PR #836 Addendum — feat(kernel): introduce construction-affordance catalog skeleton ### Agent and Session diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index 09929ade..5bc8779b 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -24,7 +24,7 @@ Design doctrine (from ADR-0223): """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -160,9 +160,9 @@ class ConstructionProposal: → 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. + A proposal is created from exact surface evidence before contract + assessment. It does not affect serving. Its initial ``status`` is + ``"proposed"``; assessment remains a separate, downstream judgment. Args: family_id: Catalog family identifier, e.g. @@ -175,6 +175,10 @@ class ConstructionProposal: assessment was runnable. missing_roles: Role obligation names that were absent at assessment time. active_hazards: Hazard categories that were unresolved at assessment time. + role_obligations: Catalog-declared roles that downstream binding and + assessment must ground or explicitly refuse. + diagnostic_only: True for every proposal in the current catalog. + serving_allowed: False for every proposal in the current catalog. """ family_id: str @@ -184,6 +188,9 @@ class ConstructionProposal: status: str missing_roles: tuple[str, ...] active_hazards: tuple[str, ...] + role_obligations: tuple[RoleObligation, ...] = () + diagnostic_only: bool = True + serving_allowed: bool = False _VALID_STATUSES: frozenset[str] = frozenset({ "proposed", "partial", "closed", "refused" @@ -195,6 +202,10 @@ class ConstructionProposal: f"ConstructionProposal.status must be one of " f"{sorted(self._VALID_STATUSES)}, got {self.status!r}" ) + if not self.diagnostic_only or self.serving_allowed: + raise ValueError( + "ConstructionProposal must remain diagnostic-only and serving-disallowed" + ) # --------------------------------------------------------------------------- @@ -405,6 +416,37 @@ def all_diagnostic_families() -> tuple[ConstructionFamily, ...]: return tuple(_CATALOG[key] for key in sorted(_CATALOG)) +def propose_construction( + family_id: str, + evidence_spans: tuple[SourceSpan, ...], +) -> ConstructionProposal: + """Create a catalog-backed proposal from pre-assessment surface evidence. + + This factory deliberately has no assessment inputs. It records the + construction hypothesis and its catalog obligations; bound relations and + ``ContractAssessment`` remain responsible for grounding and determination. + + Raises: + KeyError: If *family_id* is not registered in the catalog. + """ + family = _CATALOG[family_id] + return ConstructionProposal( + family_id=family.family_id, + relation_type=family.signature.relation_type, + candidate_organ=family.signature.candidate_organ, + evidence_spans=evidence_spans, + status="proposed", + missing_roles=(), + active_hazards=(), + role_obligations=( + *family.signature.required_roles, + *family.signature.optional_roles, + ), + diagnostic_only=family.diagnostic_only, + serving_allowed=family.serving_allowed, + ) + + def make_proposal( family_id: str, evidence_spans: tuple[SourceSpan, ...], @@ -412,11 +454,12 @@ def make_proposal( missing_roles: tuple[str, ...], active_hazards: tuple[str, ...], ) -> ConstructionProposal: - """Create a lightweight diagnostic-only ConstructionProposal from frame evidence. + """Map assessment evidence onto a proposal for legacy catalog paths. - 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. + Proportional decrease no longer uses this assessment-backed adapter; it + enters through :func:`propose_construction`. The adapter remains for + catalog families that have not yet received an authorized proposal-first + migration. Args: family_id: Catalog family identifier. @@ -436,7 +479,7 @@ def make_proposal( Raises: KeyError: If *family_id* is not registered in the catalog. """ - family = _CATALOG[family_id] # raises KeyError if absent — intended + proposal = propose_construction(family_id, evidence_spans) if assessment_runnable: status = "closed" @@ -447,11 +490,8 @@ def make_proposal( 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, + return replace( + proposal, status=status, missing_roles=missing_roles, active_hazards=active_hazards, diff --git a/generate/problem_frame.py b/generate/problem_frame.py index 55a86102..c32fc622 100644 --- a/generate/problem_frame.py +++ b/generate/problem_frame.py @@ -136,6 +136,7 @@ class ProblemFrameBuilder: self._bindings: list[MentionBinding] = [] self._bound_relations: list[BoundRelation] = [] self._bound_question_target: BoundQuestionTarget | None = None + self._proposals: list[ConstructionProposal] = [] def add_quantity(self, scalar: GroundedScalar) -> None: """Add a GroundedScalar to the frame, collecting hazards and provenance.""" @@ -199,6 +200,9 @@ class ProblemFrameBuilder: def set_bound_question_target(self, target: BoundQuestionTarget) -> None: self._bound_question_target = target + def add_proposal(self, proposal: ConstructionProposal) -> None: + self._proposals.append(proposal) + def build(self) -> ProblemFrame: """Produce the immutable ProblemFrame.""" return ProblemFrame( @@ -216,4 +220,5 @@ class ProblemFrameBuilder: bindings=tuple(self._bindings), bound_relations=tuple(self._bound_relations), bound_question_target=self._bound_question_target, + proposals=tuple(self._proposals), ) diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index 2b2e38cb..311e3ddc 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -12,9 +12,11 @@ Non-goals: """ from __future__ import annotations +import dataclasses import re from fractions import Fraction +from generate.construction_affordances import ConstructionProposal, propose_construction from generate.kernel_facts import ( BoundRelation, BoundRole, @@ -338,6 +340,25 @@ _TRANSFER_RE = re.compile( ) +def _proportional_decrease_proposals(text: str) -> tuple[ConstructionProposal, ...]: + """Propose the one authorized proposal-first construction from its chunk.""" + matches = tuple(_DECREASE_TO_FRACTION_RE.finditer(text)) + if len(matches) != 1: + return () + match = matches[0] + evidence = SourceSpan( + text[match.start():match.end()], + match.start(), + match.end(), + ) + return ( + propose_construction( + "proportional_change.decrease_to_fraction", + (evidence,), + ), + ) + + def _extract_mentions( text: str, quantities: tuple[GroundedScalar, ...], @@ -732,6 +753,12 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: if question_target is not None: builder.set_question_target(question_target) + # ADR-0223/0224 proving slice: the surface chunk proposes the catalog + # construction before role binding and ContractAssessment. Only + # proportional decrease is authorized for this controlled migration. + for proposal in _proportional_decrease_proposals(problem_text): + builder.add_proposal(proposal) + mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units) bindings = _extract_bindings(problem_text, mentions) for mention in mentions: @@ -754,10 +781,11 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: from generate.construction_affordances import make_proposal assessments = assess_contracts(initial_frame) - proposals = [] + proposals = list(initial_frame.proposals) + proposed_family_ids = {proposal.family_id for proposal in proposals} for assessment in assessments: family_id = get_contract_family_id(assessment.candidate_organ) - if family_id is not None: + if family_id is not None and family_id not in proposed_family_ids: proposal = make_proposal( family_id=family_id, evidence_spans=assessment.evidence_spans, @@ -766,8 +794,8 @@ def build_problem_frame(problem_text: str) -> ProblemFrame: active_hazards=assessment.unresolved_hazards, ) proposals.append(proposal) + proposed_family_ids.add(family_id) - import dataclasses return dataclasses.replace(initial_frame, proposals=tuple(proposals)) diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index d31c58fa..504ccf96 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -16,7 +16,6 @@ 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 @@ -296,8 +295,9 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: """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``. + 1. ``decrease_to_fraction`` — triggered by its proposal-first catalog + family in ``frame.proposals``. Routes to ``assess_fraction_decrease``, + which still determines closure from bound frame evidence. Registry key: ``_CONTRACT_REGISTRY["fraction_decrease"]``. 2. ``percent_partition`` — triggered by process-frame names ``partition`` or ``consumption``. Routes to ``assess_percent_partition``. @@ -314,7 +314,8 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: results: list[ContractAssessment] = [] # Registry-backed diagnostic families - if any(relation.relation_type == "decrease_to_fraction" for relation in frame.bound_relations): + proposed_family_ids = {proposal.family_id for proposal in frame.proposals} + if _DECREASE_TO_FRACTION_FAMILY.family_id in proposed_family_ids: # Catalog: _CONTRACT_REGISTRY["fraction_decrease"] results.append(assess_fraction_decrease(frame)) if frame_names & {"partition", "consumption"}: diff --git a/tests/test_construction_affordances.py b/tests/test_construction_affordances.py index 047144d6..e070b160 100644 --- a/tests/test_construction_affordances.py +++ b/tests/test_construction_affordances.py @@ -5,8 +5,8 @@ from generate.construction_affordances import ( lookup_by_organ, lookup_by_relation_type, make_proposal, + propose_construction, ConstructionProposal, - _CATALOG, ) from generate.kernel_facts import SourceSpan @@ -115,6 +115,27 @@ def test_make_proposal_validation_and_status() -> None: ) +def test_propose_construction_is_preassessment_and_carries_catalog_obligations() -> None: + span = SourceSpan("decrease to 3/4 of", 0, 18) + + proposal = propose_construction( + "proportional_change.decrease_to_fraction", + (span,), + ) + + assert proposal.status == "proposed" + assert proposal.missing_roles == () + assert proposal.active_hazards == () + assert proposal.diagnostic_only is True + assert proposal.serving_allowed is False + assert {role.role for role in proposal.role_obligations if role.required} == { + "base_quantity", + "scale", + "state_entity", + "transition", + } + + def test_construction_proposal_status_validation() -> None: span = SourceSpan("test text", 0, 9) # Valid status doesn't raise diff --git a/tests/test_proportional_decrease_proposal.py b/tests/test_proportional_decrease_proposal.py index 38da570a..ea28a450 100644 --- a/tests/test_proportional_decrease_proposal.py +++ b/tests/test_proportional_decrease_proposal.py @@ -1,8 +1,13 @@ """Tests for feat(kernel): route proportional-decrease through construction proposals.""" from __future__ import annotations +import dataclasses + +import generate.problem_frame_builder as problem_frame_builder +import generate.problem_frame_contracts as problem_frame_contracts from generate.problem_frame_builder import build_problem_frame from generate.construction_affordances import lookup_family +from generate.problem_frame_contracts import assess_contracts FRACTION_DECREASE_CASE = ( @@ -20,6 +25,43 @@ AFFINE_CONFUSER = ( "Marion has 1/4 more than what Yun currently has, plus 7. How many paperclips does Marion have?" ) +MULTIPLE_FRACTION_CONFUSER = ( + "The reactor's temperature will decrease to 3/4 of its temperature and later decrease " + "to 1/2 of its temperature. If the current temperature is 80 degrees, what will the " + "temperature decrease by?" +) + + +def test_proposal_precedes_role_binding_and_contract_assessment(monkeypatch) -> None: + events: list[str] = [] + original_propose = problem_frame_builder.propose_construction + original_assess = problem_frame_contracts.assess_contracts + + def observe_proposal(*args, **kwargs): + events.append("proposal") + return original_propose(*args, **kwargs) + + def observe_assessment(frame): + events.append("assessment") + proposal = next( + item + for item in frame.proposals + if item.family_id == "proportional_change.decrease_to_fraction" + ) + assert proposal.status == "proposed" + return original_assess(frame) + + monkeypatch.setattr(problem_frame_builder, "propose_construction", observe_proposal) + monkeypatch.setattr(problem_frame_contracts, "assess_contracts", observe_assessment) + + frame = build_problem_frame(FRACTION_DECREASE_CASE) + + assert events == ["proposal", "assessment"] + assert any( + relation.relation_type == "decrease_to_fraction" + for relation in frame.bound_relations + ) + def test_proposal_trace_exists_for_decrease_to_fraction() -> None: frame = build_problem_frame(FRACTION_DECREASE_CASE) @@ -29,7 +71,15 @@ def test_proposal_trace_exists_for_decrease_to_fraction() -> None: proposal = proposals[0] assert proposal.relation_type == "decrease_to_fraction" assert proposal.candidate_organ == "fraction_decrease" - assert proposal.status == "closed" + assert proposal.status == "proposed" + assert proposal.missing_roles == () + assert proposal.active_hazards == () + assert {role.role for role in proposal.role_obligations if role.required} == { + "base_quantity", + "scale", + "state_entity", + "transition", + } def test_proposal_trace_includes_exact_evidence_span() -> None: @@ -40,12 +90,8 @@ def test_proposal_trace_includes_exact_evidence_span() -> None: 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 + assert len(proposal.evidence_spans) == 1 + assert proposal.evidence_spans[0].text == "decrease to 3/4 of" def test_proposal_family_is_catalog_backed() -> None: @@ -68,6 +114,8 @@ def test_proposal_is_diagnostic_only_serving_disallowed() -> None: assert family is not None assert family.diagnostic_only is True assert family.serving_allowed is False + assert proposal.diagnostic_only is True + assert proposal.serving_allowed is False def test_blocked_final_value_proportional_case_produces_proposal_but_remains_blocked() -> None: @@ -76,12 +124,45 @@ def test_blocked_final_value_proportional_case_produces_proposal_but_remains_blo 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 + assessment = next( + item for item in assess_contracts(frame) + if item.candidate_organ == "fraction_decrease" + ) + + # The proposal remains a hypothesis; ContractAssessment alone determines closure. + assert proposal.status == "proposed" + assert not assessment.runnable + assert "delta_decrease_target_unbound" in assessment.missing_bindings 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 + + +def test_multiple_fraction_confuser_is_not_proposed_or_assessed() -> None: + frame = build_problem_frame(MULTIPLE_FRACTION_CONFUSER) + + assert not any( + proposal.family_id == "proportional_change.decrease_to_fraction" + for proposal in frame.proposals + ) + assert not any( + assessment.candidate_organ == "fraction_decrease" + for assessment in assess_contracts(frame) + ) + + +def test_fraction_contract_dispatch_requires_the_proposal_first_trace() -> None: + frame = build_problem_frame(FRACTION_DECREASE_CASE) + relation_only_frame = dataclasses.replace(frame, proposals=()) + + assert any( + relation.relation_type == "decrease_to_fraction" + for relation in relation_only_frame.bound_relations + ) + assert not any( + assessment.candidate_organ == "fraction_decrease" + for assessment in assess_contracts(relation_only_frame) + )