diff --git a/HANDOFF-gpt55-2026-06-20.md b/HANDOFF-gpt55-2026-06-20.md index bc1ac31d..cd76db90 100644 --- a/HANDOFF-gpt55-2026-06-20.md +++ b/HANDOFF-gpt55-2026-06-20.md @@ -490,3 +490,122 @@ PR #840 makes the family-spec gate structurally inspectable; it authorizes nothi - pre-edit-sweep: completed manually across imports/call sites in `generate/`, `tests/`, `evals/`, and `calibration/` - claim-proposal-guardian: not needed; no claim or proposal mutation path touched - Other: none + +--- + +## PR #842 Addendum — feat(kernel): route percent-partition 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 `partition.percent_partition` from process-frame-triggered assessment dispatch to the ADR-0223/0224 proposal-first construction seam, without changing serving or derivation organs +- **Branch:** `feat/kernel-percent-partition-proposal-first` +- **Base:** `origin/main` at `955fec3b` (merged PR #841) + +### Smoke Suite + Bootstrap Status + +```text +uv run python -m core.cli test --suite smoke -q +108 passed, 1 warning in 128.03s (0:02:08) +``` + +The warning reports an existing `engine_state` checkpoint revision mismatch (`4bab1638` versus `955fec3b`); no engine-state or runtime file was modified. `core-bootstrap` was unavailable as a skill, so the `GPT55.md` checklist was completed manually: `GPT55.md`, `AGENTS.md`, `docs/runtime_contracts.md`, and the current handoff were read; baseline state and branch ancestry were inspected; the pre-edit import/call-site/test sweep was completed. + +### Modules Touched + +| File | Change type | Summary | +|---|---|---| +| `generate/problem_frame_builder.py` | modified | Added a narrow pre-assessment percent-partition proposal factory requiring an existing partition/consumption process cue plus explicit `N% of` evidence; attaches the proposal before mention/relation binding and contract assessment. | +| `generate/problem_frame_contracts.py` | modified | Replaced percent-partition process-frame-name dispatch with catalog-family proposal-gated dispatch; left `assess_percent_partition()` structurally unchanged. | +| `tests/test_percent_partition_proposal.py` | new | Proves proposal posture/obligations/order, exact evidence spans, legacy-adapter exclusion, assessment authority, no-proposal dispatch refusal, and percent-change confuser refusal. | +| `HANDOFF-gpt55-2026-06-20.md` | modified | Added this PR #842 continuity record. | + +### Invariants Verified + +- **Field closure:** Preserved by scope and smoke. No algebra, field, propagation, normalization, vault, or runtime field-state path was touched; no field operator was added. +- **Proposal/grounding/contract duality:** `propose_construction()` creates only `status="proposed"`; exact bound relations remain grounding evidence; `assess_percent_partition()` remains the only runnable/refused authority. +- **Diagnostic-only / serving forbidden:** Catalog-derived proposal retains `diagnostic_only=True` and `serving_allowed=False`; serving train/holdout results are byte-equivalent in outcome counts with `wrong_ids == []`. +- **No legacy adapter for migrated families:** Focused regression makes `make_proposal()` fail if percent partition reaches it; the supported path remains green. +- **No broad parser or state-change runtime:** Proposal recognition is limited to the existing partition/consumption process cue plus explicit `N% of` surface evidence. The percent-change confuser can be proposed as a hypothesis but is contract-refused. +- **Exact recall / epistemic / identity:** No recall, CGA ranking, ANN/cosine, teaching mutation, pack, policy, identity, or epistemic transition path was touched. +- **Diagnostic readiness:** Train runnable count remains `2`; holdout runnable count remains `0`. + +### Subagent / Arena Reconciliation + +- Number of subagents spawned: 0 +- Each subagent independently verified versor closure: N/A +- Reconciliation: N/A + +### Tests Run + +```bash +# exit 0 — 54 passed +uv run python -m pytest -q \ + tests/test_construction_affordances.py \ + tests/test_problem_frame_builder.py \ + tests/test_problem_frame_contracts.py \ + tests/test_proportional_decrease_proposal.py \ + tests/test_percent_partition_proposal.py \ + tests/test_kernel_no_new_legacy_derivation_surfaces.py + +# exit 0 — 108 passed, one pre-existing state-revision warning +uv run python -m core.cli test --suite smoke -q + +# exit 0 +uv run ruff check \ + generate/problem_frame_builder.py \ + generate/problem_frame_contracts.py \ + tests/test_percent_partition_proposal.py + +# 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 — train serving parity +# {'correct': 30, 'wrong': 0, 'refused': 20}; wrong_ids == [] + +# exit 0 — holdout serving parity +# {'correct': 5, 'wrong': 0, 'refused': 495}; wrong_ids == [] + +# exit 0 — diagnostic readiness +# train contract_runnable_count == 2 +# holdout contract_runnable_count == 0 + +# exit 0 +git diff --check +``` + +### Open Tasks / Next Session Entry Point + +1. Review the four changed files, commit intentionally, push `feat/kernel-percent-partition-proposal-first`, and open PR #842 using the brief's required body sections. +2. Leave `make_proposal()` cleanup for a separate small follow-up; both catalog families now bypass it, but deletion was intentionally not mixed into this migration. + +### Known Hazards / Do Not Touch + +- Do not broaden `_PERCENT_OF_PROPOSAL_RE` into general percentage or state-change recognition. Proposal closeness is intentionally narrower than contract authority. +- Do not weaken `assess_percent_partition()` obligations to recover diagnostic counts; its whole/part/scale topology and forward-target checks remain load-bearing. +- Do not connect this diagnostic proposal to serving, derivation organs, `math_candidate_graph`, foundational-family implementation, pack mutation, or runtime admission. + +### Architectural Decision + +Percent partition now follows the same ordering proven by PR #841: `surface/process cue → proposed ConstructionProposal → bound percent/subgroup relations → ContractAssessment`. Process-frame similarity authorizes only a hypothesis; exact span bindings ground it; the organ-specific contract determines closure or refusal. + +### What Must Not Be Forgotten + +Both construction-affordance catalog families are now proposal-first, but proposals remain diagnostic-only and serving-disallowed. `ContractAssessment`, not `ConstructionProposal`, is the executable readiness authority. + +**Prompt Library Reference**: See `docs/core-rd-base-prompts.md` (especially sections #7 Standing Loop Axiom Check, #8 PR Merge-Readiness Audit, and #9 Grok Build Implementation Session). Use them as standing prefixes. + +### 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/`, and `calibration/` +- claim-proposal-guardian: unavailable; proposal status and serving posture verified through catalog/focused tests +- Other: none diff --git a/generate/problem_frame_builder.py b/generate/problem_frame_builder.py index 311e3ddc..4b2792fc 100644 --- a/generate/problem_frame_builder.py +++ b/generate/problem_frame_builder.py @@ -321,6 +321,10 @@ _DECREASE_TO_FRACTION_RE = re.compile( r"(?Pdecrease\s+to)\s+(?P\d+\s*/\s*\d+)\s+of", re.IGNORECASE, ) +_PERCENT_OF_PROPOSAL_RE = re.compile( + r"\b\d+(?:\.\d+)?\s*%\s+of\b", + re.IGNORECASE, +) _DECREASE_STATE_RE = re.compile( r"(?P[A-Za-z][A-Za-z'-]*)\s+will\s+decrease\s+to", re.IGNORECASE, @@ -359,6 +363,30 @@ def _proportional_decrease_proposals(text: str) -> tuple[ConstructionProposal, . ) +def _percent_partition_proposals( + text: str, + frames: tuple[ProcessFrame, ...], +) -> tuple[ConstructionProposal, ...]: + """Propose percent partition from a process cue plus explicit percent-of.""" + frame_names = {frame.name for frame in frames} + if not frame_names & {"partition", "consumption"}: + return () + + evidence_spans = tuple( + SourceSpan(text[match.start():match.end()], match.start(), match.end()) + for match in _PERCENT_OF_PROPOSAL_RE.finditer(text) + ) + if not evidence_spans: + return () + + return ( + propose_construction( + "partition.percent_partition", + evidence_spans, + ), + ) + + def _extract_mentions( text: str, quantities: tuple[GroundedScalar, ...], @@ -753,11 +781,13 @@ 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. + # ADR-0223/0224: surface/process evidence proposes catalog constructions + # before role binding and ContractAssessment. Proposals remain diagnostic + # hypotheses; bound relations ground and organ contracts determine. for proposal in _proportional_decrease_proposals(problem_text): builder.add_proposal(proposal) + for proposal in _percent_partition_proposals(problem_text, frames): + builder.add_proposal(proposal) mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units) bindings = _extract_bindings(problem_text, mentions) diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index 504ccf96..eb75bbb3 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -299,8 +299,9 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: 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``. + 2. ``percent_partition`` — triggered by its proposal-first catalog family + in ``frame.proposals``. Routes to ``assess_percent_partition``, which + still determines closure from bound frame evidence. 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 @@ -318,7 +319,7 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]: 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"}: + if _PERCENT_PARTITION_FAMILY.family_id in proposed_family_ids: # Catalog: _CONTRACT_REGISTRY["percent_partition"] results.append(assess_percent_partition(frame)) diff --git a/tests/test_percent_partition_proposal.py b/tests/test_percent_partition_proposal.py new file mode 100644 index 00000000..f8a8f27d --- /dev/null +++ b/tests/test_percent_partition_proposal.py @@ -0,0 +1,153 @@ +"""Percent-partition proposal-first construction seam tests.""" + +from __future__ import annotations + +import dataclasses + +import generate.construction_affordances as construction_affordances +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.problem_frame_contracts import assess_contracts + + +PERCENT_PARTITION_CASE = ( + "A school has 100 students. Half of the students are girls, the other half are boys. " + "20% of the girls have dogs at home and 10% of the boys have dogs at home. " + "How many students own dogs?" +) + +ONE_SUBGROUP_CONFUSER = ( + "There are 100 students. Half are girls. 30% of the girls own pets. " + "How many students own pets?" +) + +PERCENT_CHANGE_CONFUSER = "A store spent 20% of its budget. How many dollars remain?" + + +def _percent_partition_proposal(frame): + return next( + proposal + for proposal in frame.proposals + if proposal.family_id == "partition.percent_partition" + ) + + +def _percent_partition_assessment(frame): + return next( + assessment + for assessment in assess_contracts(frame) + if assessment.candidate_organ == "percent_partition" + ) + + +def test_supported_case_has_diagnostic_catalog_proposal() -> None: + proposal = _percent_partition_proposal(build_problem_frame(PERCENT_PARTITION_CASE)) + + assert proposal.status == "proposed" + assert proposal.diagnostic_only is True + assert proposal.serving_allowed is False + assert {role.role for role in proposal.role_obligations if role.required} == { + "whole", + "part", + "scale", + } + assert tuple(span.text for span in proposal.evidence_spans) == ( + "20% of", + "10% of", + ) + assert all( + PERCENT_PARTITION_CASE[span.start : span.end] == span.text + for span in proposal.evidence_spans + ) + + +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): + proposal = original_propose(*args, **kwargs) + if proposal.family_id == "partition.percent_partition": + events.append("proposal") + return proposal + + def observe_assessment(frame): + events.append("assessment") + assert _percent_partition_proposal(frame).status == "proposed" + assert frame.bound_relations + return original_assess(frame) + + monkeypatch.setattr(problem_frame_builder, "propose_construction", observe_proposal) + monkeypatch.setattr(problem_frame_contracts, "assess_contracts", observe_assessment) + + build_problem_frame(PERCENT_PARTITION_CASE) + + assert events == ["proposal", "assessment"] + + +def test_migrated_family_does_not_use_legacy_assessment_adapter(monkeypatch) -> None: + original_make_proposal = construction_affordances.make_proposal + + def reject_migrated_family(*args, **kwargs): + family_id = kwargs.get("family_id", args[0] if args else None) + if family_id == "partition.percent_partition": + raise AssertionError("migrated family reached legacy make_proposal adapter") + return original_make_proposal(*args, **kwargs) + + monkeypatch.setattr( + construction_affordances, + "make_proposal", + reject_migrated_family, + ) + + assert ( + _percent_partition_proposal(build_problem_frame(PERCENT_PARTITION_CASE)).status + == "proposed" + ) + + +def test_contract_assessment_remains_runnable_authority() -> None: + frame = build_problem_frame(PERCENT_PARTITION_CASE) + + assert _percent_partition_proposal(frame).status == "proposed" + assert _percent_partition_assessment(frame).runnable is True + + +def test_positive_proposal_does_not_close_missing_subgroup_bindings() -> None: + frame = build_problem_frame(ONE_SUBGROUP_CONFUSER) + assessment = _percent_partition_assessment(frame) + + assert _percent_partition_proposal(frame).status == "proposed" + assert assessment.runnable is False + assert "partition_subgroups_not_distinct" in assessment.missing_bindings + assert "percent_subgroup_links_incomplete" in assessment.missing_bindings + + +def test_percent_partition_dispatch_requires_proposal_first_trace() -> None: + frame = build_problem_frame(PERCENT_PARTITION_CASE) + relation_only_frame = dataclasses.replace(frame, proposals=()) + + assert {candidate.name for candidate in relation_only_frame.process_frames} & { + "partition", + "consumption", + } + assert any( + relation.relation_type in {"subgroup_partition", "percent_of"} + for relation in relation_only_frame.bound_relations + ) + assert not any( + assessment.candidate_organ == "percent_partition" + for assessment in assess_contracts(relation_only_frame) + ) + + +def test_percent_change_confuser_is_proposed_but_not_runnable() -> None: + frame = build_problem_frame(PERCENT_CHANGE_CONFUSER) + assessment = _percent_partition_assessment(frame) + + assert _percent_partition_proposal(frame).status == "proposed" + assert assessment.runnable is False + assert "grounded_partition_subgroup" in assessment.missing_bindings + assert "percent_change_vs_percent_of" in assessment.unresolved_hazards