feat(kernel): route proportional-decrease through proposal-first seam (#841)

This commit is contained in:
Shay 2026-06-20 15:37:40 -07:00 committed by GitHub
parent 168c9f26ac
commit 955fec3bc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 323 additions and 32 deletions

View file

@ -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 <fraction> 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

View file

@ -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,

View file

@ -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),
)

View file

@ -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))

View file

@ -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"}:

View file

@ -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

View file

@ -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)
)