feat(kernel): implement ProblemFrame proportional-change closure (PR slice 1) (#835)

This commit is contained in:
Shay 2026-06-20 10:58:43 -07:00 committed by GitHub
parent b8330477a4
commit 0a168bfb99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 759 additions and 134 deletions

View file

@ -1,136 +1,179 @@
# HANDOFF — GPT-5.5 — 2026-06-20
## Agent and session
## Agent and Session
- **Agent:** GPT-5.5 / Codex
- **Reasoning effort:** high architecture review
- **Session entry point:** produce the master implementation plan for moving math
comprehension from local raw-text organs to substrate-mediated constructional
cognition; do not implement runtime code.
- **Baseline:** `origin/main@60df4fc0`, merged audit PR #833.
- **Branch:** `codex/semantic-substrate-problem-solving-plan`
- **Agent:** gpt55
- **Date:** 2026-06-20
- **Reasoning effort used:** high
- **Grok Build mode used:** Headless
- **Session entry point:** implement the first PR from the semantic-substrate problem-solving master plan: close proportional-decrease contracts and make readiness obligation-sound without touching serving or derivation organs
## Bootstrap and smoke
---
Read before editing:
- `GPT55.md`
- `AGENTS.md`
- `docs/runtime_contracts.md`
- the ADR-0223 audit/session pack and previous handoff
- current `ProblemFrame`, contracts, derivation boundaries, pack/compiler,
graph-carrier boundaries, governing ADRs, diagnostics, and focused tests
Direct CLI result:
## Smoke Suite + Bootstrap Status
```text
Read before editing:
- GPT55.md
- AGENTS.md
- docs/runtime_contracts.md
- HANDOFF-gpt55-2026-06-20.md (prior planning handoff, now superseded by this file)
- ADR-0223 audit/session pack and targeted ProblemFrame files/tests
core test --suite smoke -q
zsh: command not found: core
```
Repository-supported fallback:
```text
uv run python -m core.cli test --suite smoke -q
108 passed, 1 warning in 129.78s
108 passed in 121.54s (0:02:01)
```
The warning reports a stale `engine_state` revision. This docs-only task did not clear
or mutate engine state.
`core-bootstrap` skill was not available in this session; bootstrap was completed manually.
## Modules touched
---
| File | Change | Summary |
## Modules Touched
| File | Change type | Summary |
|---|---|---|
| `docs/analysis/semantic-substrate-problem-solving-master-plan-2026-06-20.md` | added | 14-section master architecture, catalog, PR sequence, immediate PR specification, metrics, retirement, governance, validation, risk, and exact handoff prompt |
| `HANDOFF-gpt55-2026-06-20.md` | updated | this bounded implementation handoff, superseding the audit-session handoff now captured by the merged audit artifacts |
| `generate/problem_frame.py` | modified | extended `BoundQuestionTarget` with typed operator/state/direction validation so delta-decrease targets are structurally representable |
| `generate/problem_frame_builder.py` | modified | added narrow `decrease_to_fraction` binding, copular half-partition strengthening, canonical entity grounding, and richer bound target detection |
| `generate/problem_frame_contracts.py` | modified | added `assess_fraction_decrease`; tightened `percent_partition` to require forward aggregate topology, original whole provenance, and explicit blocker codes |
| `scripts/gsm8k_problem_frame_adequacy.py` | modified | surfaced blocker sets and blocker-combination aggregates by organ |
| `scripts/gsm8k_substrate_morphology.py` | modified | surfaced per-organ blockers and kept non-runnable decrease cases on a substrate contract-gap recommendation instead of a serving-organ recommendation |
| `tests/test_problem_frame_builder.py` | modified | added proportional-decrease binding/span tests |
| `tests/test_problem_frame_contracts.py` | replaced | added positive/negative obligation tests for fraction decrease and percent partition |
| `tests/test_problem_frame_skeleton.py` | modified | added `BoundQuestionTarget` validation coverage |
| `tests/test_gsm8k_problem_frame_adequacy.py` | modified | added blocker-by-organ/blocker-combination adequacy coverage |
| `tests/test_gsm8k_morphology_missing_kernel_labels.py` | modified | added morphology contract-gap recommendation coverage |
No Python, runtime, pack, manifest, benchmark, report, case, sealed, test, or UI file
was modified.
---
## Decisions
## Invariants Verified (Versor Coherence Guardian + Core)
1. The transformation boundary is:
| Invariant | Check performed | Result | Notes |
|---|---|---|---|
| `||F * reverse(F) - 1||_F < 1e-6` (core closure) | Scope review + smoke suite rerun | Preserved | No algebra/field/runtime field-op code touched |
| versor_apply / cga_inner exactness | Manual sweep of touched files/imports | Preserved | No recall/ranking/operator-path changes |
| Normalization boundaries respected | Pre-edit sweep + patch review | Preserved | No normalization added outside approved construction/IR boundary |
| No approximate recall (ANN/HNSW/cosine) | Import/call-site sweep on touched path | Preserved | Diagnostic path remains exact/deterministic |
| Claim status transitions via review gates only | Scope review | Preserved | Teaching/review/proposal paths untouched |
| Safety/identity pack immutability | Scope review | Preserved | No pack, manifest, policy, identity, or recall edits |
| INV-21 / INV-24 / INV-29 (Vault & epistemic) | Scope review + serving parity | Preserved | No memory mutation or serving-path widening |
| Wrong-zero serving | Direct runner `build_report()` checks | Preserved | Train `30/20/0`, holdout `5/495/0`, `wrong_ids == []` |
| Readiness truthfulness | Targeted adequacy reruns | Improved | Train runnable `1 -> 2`; holdout runnable `1 -> 0`; `0393` now blocked |
```text
chunk -> exact substrate proposal -> construction candidate -> role obligations
-> exact bindings -> ProblemFrame -> ContractAssessment -> typed organ input
-> verified derivation -> existing admission/articulation
```
---
2. Semantic closeness proposes; exact bindings ground; contracts determine;
verification admits. CGA score never proves a relation or selects an operator.
3. The next implementation PR remains proportional-decrease/readiness closure.
4. Serving migration is deferred until a contract runs from `ProblemFrame` alone and
passes typed derivation, verifier, morphology, confuser, replay, and parity evidence.
5. Parser retirement is one organ at a time. Migration is incomplete until the raw
parser and no-new-legacy allowlist entry are deleted.
6. `ProblemFrame`, math state, `EpistemicGraph`, and `PropositionGraph` remain
purpose-specific. Typed adapters connect them; no universal graph is introduced.
7. ADR-0223 status is stale (`Proposed`) despite the merged audit. Architect
ratification/status reconciliation is required before the next implementation PR
merges.
## Subagent / Arena Reconciliation (if applicable)
## Invariants reviewed
- Number of subagents spawned: 0
- Each subagent independently verified versor closure? (Yes/No): N/A
- How were results reconciled before merge? N/A
| Invariant | Result |
|---|---|
| `versor_condition(F) < 1e-6` | Not mechanically perturbed; no field/algebra/runtime code changed. |
| Exact CGA only | Plan requires exact O(N) candidate scans and forbids ANN/HNSW/cosine substitution. |
| Normalization boundaries | No normalization proposed outside approved construction/algebra boundaries. |
| Wrong-zero serving | Every promotion gate retains `wrong_ids == []`; false-runnable removal counts as progress. |
| Reviewed mutation | Catalog/pack changes remain checksummed, reviewed, data-only; runtime learning is proposal-only. |
| INV-21/24/29/30 | No mutation or open-/closed-world determination path touched or widened. |
| Graph separation | ADR-0144 and ADR-0207 boundaries are explicit in the plan. |
---
No subagents were used.
## Tests Run
## Exact next PR
```bash
# exit 0
uv run python -m pytest -q \
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
```text
branch: codex/problemframe-proportional-change-closure
title: feat(kernel): close proportional-decrease contracts and make readiness obligation-sound
agent: Codex or Opus, high reasoning; mandatory XHIGH merge-blocker review
# exit 0
uv run python -m pytest -q \
tests/test_problem_frame_builder.py \
tests/test_problem_frame_contracts.py \
tests/test_problem_frame_skeleton.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
# exit 0
uv run python -m core.cli test --suite smoke -q
# exit 0
uv run python -m scripts.gsm8k_problem_frame_adequacy \
--cases evals/gsm8k_math/train_sample/v1/cases.jsonl
# exit 0
uv run python -m scripts.gsm8k_problem_frame_adequacy \
--cases evals/gsm8k_math/holdout_dev/v1/cases.jsonl
# exit 0
uv run python - <<'PY'
from evals.gsm8k_math.train_sample.v1.runner import _CASES_PATH, _load_cases, build_report
r = build_report(_load_cases(_CASES_PATH))
print(r["counts"])
print([x["case_id"] for x in r["per_case"] if x["verdict"] == "wrong"])
PY
# exit 0
uv run python - <<'PY'
from evals.gsm8k_math.holdout_dev.v1.runner import build_report
r = build_report()
print(r["counts"])
print([x["case_id"] for x in r["per_case"] if x["verdict"] == "wrong"])
PY
# exit 0
git diff --check
git status --short
git diff --name-only origin/main...
```
Required outcome:
Key outcomes:
- bind `decrease_to_fraction` for train `0005` from exact facts/spans;
- represent a typed decrease-delta question target;
- make `fraction_decrease` diagnostically runnable for `0005`;
- retain `percent_partition` runnable for `0046` only under explicit topology;
- make holdout `0393` non-runnable for forward percent partition;
- keep serving unchanged and `wrong_ids=[]`.
- train adequacy `contract_runnable_count == 2`
- runnable train cases are `gsm8k-train-sample-v1-0005` and `gsm8k-train-sample-v1-0046`
- holdout adequacy `contract_runnable_count == 0`
- `gsm8k-holdout-dev-v1-0393` now blocks with inverse/original-whole topology reasons
- serving unchanged: train `{'correct': 30, 'wrong': 0, 'refused': 20}`, holdout `{'correct': 5, 'wrong': 0, 'refused': 495}`
Forbidden scope:
---
- `generate/derivation/*`
- `generate/math_candidate_graph.py`
- serving changes or raw-text fallback
- report, case, sealed, pack, or manifest changes
- case-ID behavior, generic parsing, hidden normalization, or approximate retrieval
## Open Tasks / Next Session Entry Point
Expected diagnostics:
1. Inspect whether any non-0046 percent-partition candidates are now worth a narrower contract split instead of accumulating blocker lists on one forward organ.
2. Decide whether the next substrate PR should introduce explicit typed gap records (`PF-*` codes as first-class data) or keep using stable blocker strings until the construction catalog lands.
3. If a future PR starts typed adapter work, consume `fraction_decrease` only from `ProblemFrame` facts and do not reparse raw text inside any adapter or derivation organ.
```text
train runnable: 1 -> 2 (0005, 0046)
holdout runnable: 1 -> 0
train serving: 30 correct / 20 refused / 0 wrong
holdout serving: 5 correct / 495 refused / 0 wrong
wrong_ids: []
```
---
## Implementation entry prompt
## Known Hazards / Do Not Touch
Use the full exact prompt in §14 of
`docs/analysis/semantic-substrate-problem-solving-master-plan-2026-06-20.md`.
Before editing, the implementer must state the exact files and explain how the new
relation/target/contract types make false readiness unrepresentable. At handoff, list
every positive obligation, confuser, metric, and forbidden-diff check proven.
- Do not edit `generate/derivation/*` or `generate/math_candidate_graph.py` to “finish” this slice. This PR is diagnostic-only and already proved serving parity.
- The new builder logic is intentionally narrow. Do not generalize `decrease_to_fraction` into `decrease by`, percent change, or affine fraction comparison without a separate contract/confuser pack.
- `percent_partition` is now deliberately stricter. A runnable-count drop elsewhere is acceptable if it removes false readiness; do not loosen obligations to make the number larger.
- `HANDOFF-gpt55-2026-06-20.md` was replaced; do not rely on the earlier planning-only contents.
## Do not forget
---
The immediate PR closes diagnostic obligations; it does not complete semantic
substrate retrieval. Do not call bounded local construction matching “geometric” or
“substrate-mediated.” The reviewed construction catalog and exact CGA proposal seam
are later, separate PRs.
## Architectural Decisions Made This Session
- `BoundQuestionTarget` now carries typed operator/state/direction, with validation that blocks illegal delta/final combinations at construction time.
- `decrease_to_fraction` is represented as a span-grounded `BoundRelation` in `ProblemFrame`, not as a derivation-organ shortcut.
- Readiness is keyed to exact grounded IDs from the frame. Repeated entity mentions are canonicalized at relation construction so contracts can match by identity rather than lowercase text equality.
- `percent_partition` requires a forward aggregate target and an original whole bound before subgroup events. Final-state/inverse prompts are blocked rather than treated as forward partition.
- Adequacy and morphology reports now expose blocker combinations by organ, making false readiness auditable without touching serving.
---
## What Must Not Be Forgotten
The substantive result of this session is not just “0005 runnable.” It is that `runnable` now means obligation-closed from `ProblemFrame` evidence alone: `0005` closes through `decrease_to_fraction`, `0046` closes only under explicit complementary-subgroup topology, and `0393` is blocked because its target is inverse/original-whole rather than a forward aggregate.
**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 This Session
- core-bootstrap: not available
- versor-coherence-guardian: not available
- pre-edit-sweep: manual import/call-site sweep completed
- claim-proposal-guardian: not needed
- Other: none

View file

@ -52,6 +52,41 @@ class BoundQuestionTarget:
target_mention_id: str | None
unknown_slot: str
evidence_spans: tuple[SourceSpan, ...]
target_operator: str = "unknown"
target_state: str = "unknown"
target_direction: str = "unknown"
def __post_init__(self) -> None:
valid_types = {"count", "difference", "remaining", "total", "unknown"}
valid_operators = {"count", "difference", "comparison", "unknown"}
valid_states = {"aggregate", "current", "delta", "final", "initial", "unknown"}
valid_directions = {"decrease", "forward", "inverse", "remaining", "unknown"}
if self.target_type not in valid_types:
raise ValueError(
f"BoundQuestionTarget.target_type must be one of {sorted(valid_types)}, "
f"got {self.target_type!r}"
)
if self.target_operator not in valid_operators:
raise ValueError(
"BoundQuestionTarget.target_operator must be one of "
f"{sorted(valid_operators)}, got {self.target_operator!r}"
)
if self.target_state not in valid_states:
raise ValueError(
"BoundQuestionTarget.target_state must be one of "
f"{sorted(valid_states)}, got {self.target_state!r}"
)
if self.target_direction not in valid_directions:
raise ValueError(
"BoundQuestionTarget.target_direction must be one of "
f"{sorted(valid_directions)}, got {self.target_direction!r}"
)
if self.target_operator == "difference" and self.target_state != "delta":
raise ValueError("difference targets must bind a delta target_state")
if self.target_state == "delta" and self.target_operator != "difference":
raise ValueError("delta targets must use the difference operator")
if self.target_direction == "decrease" and self.target_operator != "difference":
raise ValueError("decrease-directed targets must use the difference operator")
@property
def grounded(self) -> bool:

View file

@ -176,6 +176,25 @@ def _trigger_span(text: str, trigger: str) -> SourceSpan | None:
return SourceSpan(text[match.start():match.end()], match.start(), match.end())
def _sentence_contains_current_or_now(text: str, index: int) -> bool:
start = max(
text.rfind(".", 0, index),
text.rfind("?", 0, index),
text.rfind("!", 0, index),
)
end_candidates = [
pos for pos in (
text.find(".", index),
text.find("?", index),
text.find("!", index),
)
if pos != -1
]
end = min(end_candidates) if end_candidates else len(text)
sentence = text[start + 1:end].lower()
return "current" in sentence or "now" in sentence
def _extract_process_frame_candidates(text: str) -> tuple[ProcessFrame, ...]:
text_lower = text.lower()
matched: dict[str, ProcessFrame] = {}
@ -291,6 +310,23 @@ _QUESTION_ENTITY_RE = re.compile(
r"\bhow\s+(?:many|much)\s+(?:more\s+)?(?P<entity>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
_COPULAR_PARTITION_RE = re.compile(
r"\b(?P<quantity>half|third|quarter)\b\s+of\s+(?:the\s+)?"
r"(?P<whole>[A-Za-z][A-Za-z'-]*)\s+(?:are|is)\s+(?P<part>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
_DECREASE_TO_FRACTION_RE = re.compile(
r"(?P<transition>decrease\s+to)\s+(?P<fraction>\d+\s*/\s*\d+)\s+of",
re.IGNORECASE,
)
_DECREASE_STATE_RE = re.compile(
r"(?P<state>[A-Za-z][A-Za-z'-]*)\s+will\s+decrease\s+to",
re.IGNORECASE,
)
_DECREASE_DELTA_QUESTION_RE = re.compile(
r"\bwhat\s+will\s+the\s+(?P<entity>[A-Za-z][A-Za-z'-]*)\s+decrease\s+by\??",
re.IGNORECASE,
)
_ACTOR_VERB_RE = re.compile(
r"\b(?P<actor>[A-Z][A-Za-z'-]*)\s+"
r"(?:gave|gives|give|received|receives|spent|spends|ate|eats|bought|buys|sold|sells)\b"
@ -327,6 +363,11 @@ def _extract_mentions(
for pattern in (_ENTITY_AFTER_QUANTITY_RE, _FRACTION_ENTITY_RE, _QUESTION_ENTITY_RE):
for match in pattern.finditer(text):
add("object", match.start("entity"), match.end("entity"))
for match in _COPULAR_PARTITION_RE.finditer(text):
add("object", match.start("whole"), match.end("whole"))
add("object", match.start("part"), match.end("part"))
for match in _DECREASE_STATE_RE.finditer(text):
add("object", match.start("state"), match.end("state"))
for match in _ACTOR_VERB_RE.finditer(text):
add("actor", match.start("actor"), match.end("actor"))
for match in _TRANSFER_RE.finditer(text):
@ -415,10 +456,19 @@ def _bound_relations(
for binding in quantity_entity:
quantity = by_id[binding.source_mention_id]
part = by_id[binding.target_mention_id]
canonical_part = min(
(
mention
for mention in mentions
if mention.kind == part.kind and mention.surface.lower() == part.surface.lower()
),
key=lambda mention: mention.span.start,
default=part,
)
if "%" not in quantity.surface and quantity.surface.lower() not in {"half", "third", "quarter"}:
continue
roles = [
BoundRole("part", part.mention_id, part.kind, (part.span,)),
BoundRole("part", canonical_part.mention_id, canonical_part.kind, (canonical_part.span,)),
BoundRole("scale", quantity.mention_id, quantity.kind, (quantity.span,)),
]
if whole is not None:
@ -430,6 +480,94 @@ def _bound_relations(
evidence_spans=tuple(span for role in roles for span in role.evidence_spans),
))
for match in _COPULAR_PARTITION_RE.finditer(text):
quantity = next((m for m in mentions if m.kind == "quantity" and m.span.start == match.start("quantity")), None)
whole = next((m for m in mentions if m.kind == "object" and m.span.start == match.start("whole")), None)
part = next((m for m in mentions if m.kind == "object" and m.span.start == match.start("part")), None)
if quantity is None or whole is None or part is None:
continue
canonical_whole = min(
(
mention
for mention in mentions
if mention.kind == "object" and mention.surface.lower() == whole.surface.lower()
),
key=lambda mention: mention.span.start,
default=whole,
)
roles = (
BoundRole("whole", canonical_whole.mention_id, canonical_whole.kind, (canonical_whole.span,)),
BoundRole("part", part.mention_id, part.kind, (part.span,)),
BoundRole("scale", quantity.mention_id, quantity.kind, (quantity.span,)),
)
relations.append(BoundRelation(
relation_id="",
relation_type="subgroup_partition",
roles=roles,
evidence_spans=(quantity.span, canonical_whole.span, part.span),
))
decrease_matches = list(_DECREASE_TO_FRACTION_RE.finditer(text))
if len(decrease_matches) == 1:
match = decrease_matches[0]
scale = next((m for m in mentions if m.kind == "quantity" and m.span.start == match.start("fraction")), None)
state_match = next(
(
item
for item in _DECREASE_STATE_RE.finditer(text)
if item.start("state") < match.start("transition")
),
None,
)
state = (
next(
(
m for m in mentions
if m.kind == "object" and state_match is not None and m.span.start == state_match.start("state")
),
None,
)
if state_match is not None
else None
)
unit_binding_by_quantity = {
binding.source_mention_id: binding
for binding in bindings
if binding.binding_type == "quantity_unit"
}
base_candidates = [
mention
for mention in mentions
if mention.kind == "quantity"
and mention.mention_id != (scale.mention_id if scale else None)
and mention.mention_id in unit_binding_by_quantity
and _sentence_contains_current_or_now(text, mention.span.start)
]
if len(base_candidates) == 1 and scale is not None and state is not None:
base = base_candidates[0]
base_unit_binding = unit_binding_by_quantity.get(base.mention_id)
roles = [
BoundRole("base_quantity", base.mention_id, base.kind, (base.span,)),
BoundRole("scale", scale.mention_id, scale.kind, (scale.span,)),
BoundRole("state_entity", state.mention_id, state.kind, (state.span,)),
BoundRole(
"transition",
f"span:{match.start('transition')}:{match.end('transition')}",
"span",
(SourceSpan(text[match.start("transition"):match.end("transition")], match.start("transition"), match.end("transition")),),
),
]
if base_unit_binding is not None:
unit = by_id.get(base_unit_binding.target_mention_id)
if unit is not None:
roles.append(BoundRole("unit", unit.mention_id, unit.kind, (unit.span,)))
relations.append(BoundRelation(
relation_id="",
relation_type="decrease_to_fraction",
roles=tuple(roles),
evidence_spans=tuple(span for role in roles for span in role.evidence_spans),
))
for match in _TRANSFER_RE.finditer(text):
def at(group: str, kind: str) -> GroundedMention | None:
return next((m for m in mentions if m.kind == kind and m.span.start == match.start(group)), None)
@ -462,6 +600,27 @@ def _bound_relations(
def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) -> BoundQuestionTarget | None:
decrease_delta = _DECREASE_DELTA_QUESTION_RE.search(text)
if decrease_delta is not None:
entity_surface = decrease_delta.group("entity")
entity = next(
(
m for m in mentions
if m.kind == "object" and m.surface.lower() == entity_surface.lower()
),
None,
)
span = SourceSpan(text[decrease_delta.start():decrease_delta.end()], decrease_delta.start(), decrease_delta.end())
return BoundQuestionTarget(
"difference",
entity_surface,
entity.mention_id if entity else None,
"delta_quantity",
(span,),
target_operator="difference",
target_state="delta",
target_direction="decrease",
)
question = _QUESTION_ENTITY_RE.search(text)
if question is None:
if "?" not in text:
@ -470,14 +629,51 @@ def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) ->
return BoundQuestionTarget(
"unknown", "?", None, "unresolved",
(SourceSpan("?", qmark, qmark + 1),),
target_operator="unknown",
target_state="unknown",
target_direction="unknown",
)
entity = next((m for m in mentions if m.kind == "object" and m.span.start == question.start("entity")), None)
prefix = text[max(0, question.start() - 24):question.end()].lower()
target_type = "difference" if "more" in question.group(0).lower() else "remaining" if any(x in prefix for x in ("remaining", "left")) else "total" if any(x in prefix for x in ("total", "altogether")) else "count"
question_clause = text[question.start():]
prefix = text[max(0, question.start() - 32):question.end()].lower()
question_lower = question_clause.lower()
if "more" in question.group(0).lower():
target_type = "difference"
target_operator = "difference"
target_state = "delta"
target_direction = "unknown"
unknown_slot = "difference"
elif any(x in question_lower for x in ("were in", "was in", "started with", "originally")):
target_type = "count"
target_operator = "count"
target_state = "initial"
target_direction = "inverse"
unknown_slot = "initial"
elif any(x in prefix for x in ("remaining", "left")):
target_type = "remaining"
target_operator = "count"
target_state = "final"
target_direction = "remaining"
unknown_slot = "remaining"
elif any(x in question_lower for x in ("total", "altogether", "own")):
target_type = "count"
target_operator = "count"
target_state = "aggregate"
target_direction = "forward"
unknown_slot = "count"
else:
target_type = "count"
target_operator = "count"
target_state = "current"
target_direction = "unknown"
unknown_slot = "count"
span = SourceSpan(text[question.start():question.end()], question.start(), question.end())
return BoundQuestionTarget(
target_type, question.group("entity"),
entity.mention_id if entity else None, target_type, (span,),
entity.mention_id if entity else None, unknown_slot, (span,),
target_operator=target_operator,
target_state=target_state,
target_direction=target_direction,
)

View file

@ -39,51 +39,194 @@ def _evidence(frame: ProblemFrame, relation_type: str) -> tuple[SourceSpan, ...]
return tuple(spans[key] for key in sorted(spans))
def _role_target(relation: BoundRelation, role_name: str) -> str | None:
return next((role.target_id for role in relation.roles if role.role == role_name), None)
def _mention_map(frame: ProblemFrame) -> dict[str, object]:
return {mention.mention_id: mention for mention in frame.mentions}
def _quantity_value_by_mention_id(frame: ProblemFrame) -> dict[str, object]:
quantities = {quantity.fact_id: quantity for quantity in frame.quantities}
return {
mention.mention_id: quantities[mention.fact_id]
for mention in frame.mentions
if mention.fact_id is not None and mention.fact_id in quantities
}
def _quantity_entity_bindings(frame: ProblemFrame) -> tuple[tuple[str, str], ...]:
return tuple(
(binding.source_mention_id, binding.target_mention_id)
for binding in frame.bindings
if binding.binding_type == "quantity_entity"
)
def _quantity_unit_bindings(frame: ProblemFrame) -> dict[str, str]:
return {
binding.source_mention_id: binding.target_mention_id
for binding in frame.bindings
if binding.binding_type == "quantity_unit"
}
def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
mentions = _mention_map(frame)
quantities = _quantity_value_by_mention_id(frame)
quantity_units = _quantity_unit_bindings(frame)
relations = [relation for relation in frame.bound_relations if relation.relation_type == "decrease_to_fraction"]
question_target = frame.bound_question_target
missing: list[str] = []
unresolved: set[str] = set()
if len(relations) != 1:
missing.append("decrease_relation_ambiguous")
relation = relations[0] if len(relations) == 1 else None
base_id = _role_target(relation, "base_quantity") if relation is not None else None
scale_id = _role_target(relation, "scale") if relation is not None else None
state_id = _role_target(relation, "state_entity") if relation is not None else None
unit_id = _role_target(relation, "unit") if relation is not None else None
if base_id is None:
missing.append("base_quantity_unbound")
if scale_id is None:
missing.append("scale_unbound")
if state_id is None:
missing.append("state_entity_unbound")
if base_id is not None and base_id not in quantities:
missing.append("base_quantity_provenance_missing")
if scale_id is not None and scale_id not in quantities:
missing.append("scale_provenance_missing")
if unit_id is not None and base_id is not None and quantity_units.get(base_id) != unit_id:
missing.append("unit_continuity_unproven")
if question_target is None or not question_target.grounded:
missing.append("delta_decrease_target_unbound")
else:
if not (
question_target.target_operator == "difference"
and question_target.target_state == "delta"
and question_target.target_direction == "decrease"
):
missing.append("delta_decrease_target_required")
if state_id is not None and state_id in mentions and question_target.target_mention_id in mentions:
relation_state = mentions[state_id]
target_state = mentions[question_target.target_mention_id]
if relation_state.surface.lower() != target_state.surface.lower():
missing.append("state_entity_continuity_unproven")
scale = quantities.get(scale_id) if scale_id is not None else None
if scale is not None and not (0 < scale.value < 1):
missing.append("scale_out_of_range")
categories = {hazard.category for hazard in frame.hazards}
if any(item.startswith("base_quantity") for item in missing) and "unbound_base_quantity" in categories:
unresolved.add("unbound_base_quantity")
evidence_spans = (
_evidence(frame, "decrease_to_fraction")
if relation is None
else tuple(
sorted(
{
(span.start, span.end, span.text): span
for span in (*relation.evidence_spans, *(question_target.evidence_spans if question_target else ()))
}.values(),
key=lambda span: (span.start, span.end, span.text),
)
)
)
runnable = not missing and not unresolved
return ContractAssessment(
candidate_organ="fraction_decrease",
missing_bindings=tuple(dict.fromkeys(missing)),
unresolved_hazards=tuple(sorted(unresolved)),
runnable=runnable,
explanation=(
"all fraction-decrease roles and delta target obligations are grounded"
if runnable else "diagnostic candidate is not runnable: " + ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
),
evidence_spans=evidence_spans,
)
def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment:
mentions = {mention.mention_id: mention for mention in frame.mentions}
quantities = _quantity_value_by_mention_id(frame)
quantity_entity = _quantity_entity_bindings(frame)
subgroups = [relation for relation in frame.bound_relations if relation.relation_type == "subgroup_partition"]
percentages = [relation for relation in frame.bound_relations if relation.relation_type == "percent_of"]
linked_pairs: list[tuple[BoundRelation, BoundRelation]] = []
subgroup_part_ids: set[str] = set()
shared_whole_ids: set[str] = set()
original_whole_quantities: set[str] = set()
def role_target(relation: BoundRelation, role_name: str) -> str | None:
return next((role.target_id for role in relation.roles if role.role == role_name), None)
linked_pairs = []
for subgroup in subgroups:
subgroup_part = role_target(subgroup, "part")
if subgroup_part is None or subgroup_part not in mentions:
subgroup_part = _role_target(subgroup, "part")
subgroup_whole = _role_target(subgroup, "whole")
if subgroup_part is None or subgroup_whole is None:
continue
subgroup_surface = mentions[subgroup_part].surface.lower()
subgroup_part_ids.add(subgroup_part)
shared_whole_ids.add(subgroup_whole)
relation_start = min(span.start for span in subgroup.evidence_spans)
original_whole_quantities.update(
quantity_id
for quantity_id, entity_id in quantity_entity
if entity_id == subgroup_whole
and quantity_id in mentions
and quantity_id in quantities
and mentions[quantity_id].span.start < relation_start
)
for percent in percentages:
percent_part = role_target(percent, "part")
if percent_part is not None and percent_part in mentions and mentions[percent_part].surface.lower() == subgroup_surface:
percent_part = _role_target(percent, "part")
percent_whole = _role_target(percent, "whole")
if percent_part == subgroup_part and percent_whole == subgroup_whole:
linked_pairs.append((subgroup, percent))
missing: list[str] = []
if not any(role_target(relation, "whole") for relation in subgroups):
missing.append("grounded_whole_entity")
if not subgroups:
missing.append("grounded_partition_subgroup")
if not linked_pairs:
missing.append("percent_or_fraction_linked_to_subgroup")
if not shared_whole_ids:
missing.append("grounded_whole_entity")
if not original_whole_quantities:
missing.append("original_whole_unbound")
elif len(original_whole_quantities) != 1:
missing.append("multiple_original_whole_candidates")
if len(subgroup_part_ids) < 2:
missing.append("partition_subgroups_not_distinct")
if len(linked_pairs) < 2:
missing.append("percent_subgroup_links_incomplete")
question_target = frame.bound_question_target
if question_target is None or not question_target.grounded:
missing.append("grounded_question_target")
elif not (
question_target.target_operator == "count"
and question_target.target_state == "aggregate"
and question_target.target_direction == "forward"
):
if question_target.target_state == "initial" and question_target.target_direction == "inverse":
missing.extend(("inverse_topology_unlicensed", "forward_aggregate_target_required"))
else:
missing.append("forward_aggregate_target_required")
unresolved: set[str] = set()
categories = {hazard.category for hazard in frame.hazards}
if "grounded_whole_entity" in missing and "unbound_base_quantity" in categories:
if any(item in missing for item in ("grounded_whole_entity", "original_whole_unbound")) and "unbound_base_quantity" in categories:
unresolved.add("unbound_base_quantity")
if "grounded_partition_subgroup" in missing and "percent_change_vs_percent_of" in categories:
if any(item in missing for item in ("grounded_partition_subgroup", "percent_subgroup_links_incomplete")) and "percent_change_vs_percent_of" in categories:
unresolved.add("percent_change_vs_percent_of")
runnable = not missing and not unresolved
return ContractAssessment(
candidate_organ="percent_partition",
missing_bindings=tuple(missing),
missing_bindings=tuple(dict.fromkeys(missing)),
unresolved_hazards=tuple(sorted(unresolved)),
runnable=runnable,
explanation=(
"all percent-partition roles and the question target are grounded"
if runnable else "diagnostic candidate is not runnable: " + ", ".join((*missing, *sorted(unresolved)))
if runnable else "diagnostic candidate is not runnable: " + ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
),
evidence_spans=tuple(sorted(
{
@ -101,6 +244,8 @@ def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]:
"""Return deterministic diagnostic assessments; never admits serving."""
frame_names = {candidate.name for candidate in frame.process_frames}
results: list[ContractAssessment] = []
if any(relation.relation_type == "decrease_to_fraction" for relation in frame.bound_relations):
results.append(assess_fraction_decrease(frame))
if frame_names & {"partition", "consumption"}:
results.append(assess_percent_partition(frame))
if "container_packing" in frame_names and frame.bound_question_target is not None:

View file

@ -17,6 +17,11 @@ def assess_case(case: Mapping[str, Any], *, current_verdict: str | None = None)
case_id = str(case.get("case_id") or case.get("id") or "unknown")
frame = build_problem_frame(text)
contracts = assess_contracts(frame)
blockers_by_organ = {
item.candidate_organ: sorted({*item.missing_bindings, *item.unresolved_hazards})
for item in contracts
if not item.runnable
}
return {
"case_id": case_id,
"current_verdict": current_verdict,
@ -31,6 +36,11 @@ def assess_case(case: Mapping[str, Any], *, current_verdict: str | None = None)
"runnable_contracts": [item.candidate_organ for item in contracts if item.runnable],
"missing_binding_taxonomy": sorted({gap for item in contracts for gap in item.missing_bindings}),
"unresolved_hazards": sorted({gap for item in contracts for gap in item.unresolved_hazards}),
"blockers_by_organ": blockers_by_organ,
"blocker_combinations_by_organ": {
organ: "+".join(blockers) if blockers else "__runnable__"
for organ, blockers in blockers_by_organ.items()
},
"recommended_next_migration_target": recommended_migration_target(contracts),
}
@ -41,6 +51,16 @@ def build_report(cases: Iterable[Mapping[str, Any]], *, verdicts: Mapping[str, s
assess_case(case, current_verdict=verdicts.get(str(case.get("case_id") or case.get("id") or "unknown")))
for case in cases
]
blockers_by_organ: dict[str, dict[str, int]] = {}
blocker_combinations_by_organ: dict[str, dict[str, int]] = {}
for row in per_case:
for organ, blockers in row["blockers_by_organ"].items():
organ_counts = blockers_by_organ.setdefault(organ, {})
for blocker in blockers:
organ_counts[blocker] = organ_counts.get(blocker, 0) + 1
combo = row["blocker_combinations_by_organ"][organ]
combo_counts = blocker_combinations_by_organ.setdefault(organ, {})
combo_counts[combo] = combo_counts.get(combo, 0) + 1
return {
"schema_version": 1,
"case_count": len(per_case),
@ -55,6 +75,8 @@ def build_report(cases: Iterable[Mapping[str, Any]], *, verdicts: Mapping[str, s
"contract_candidate_count": sum(len(row["candidate_organ_contracts"]) for row in per_case),
"contract_runnable_count": sum(len(row["runnable_contracts"]) for row in per_case),
},
"blockers_by_organ": blockers_by_organ,
"blocker_combinations_by_organ": blocker_combinations_by_organ,
"per_case": per_case,
}

View file

@ -143,7 +143,7 @@ def recommend_migration_target(problem_text: str, process_frames: tuple[str, ...
if "remaining" in lowered and ("half" in lowered or "quarter" in lowered):
return "nested_fraction_remainder_total"
if "decrease" in lowered or "decreased" in lowered:
return "fraction_decrease"
return "substrate:relation_gap:decrease_to_fraction"
if "labor_rate" in process_frames or any(t in lowered for t in ("per hour", "hourly", "overtime")):
return "temporal_tariff"
target = _target_for_process_frames(process_frames)
@ -174,6 +174,11 @@ def plan_substrate_case(*, case_id: str, problem_text: str, current_verdict: str
"runnable_contracts": tuple(a.candidate_organ for a in assessments if a.runnable),
"missing_bindings": tuple(sorted({gap for a in assessments for gap in a.missing_bindings})),
"unresolved_contract_hazards": tuple(sorted({gap for a in assessments for gap in a.unresolved_hazards})),
"blockers_by_organ": {
a.candidate_organ: tuple(sorted({*a.missing_bindings, *a.unresolved_hazards}))
for a in assessments
if not a.runnable
},
"missing_substrate_labels": missing_labels,
"legacy_parser_dependency": _legacy_parser_dependency(problem_text, process_frames, missing_labels),
"recommended_migration_target": contract_target(assessments) if assessments else recommend_migration_target(problem_text, process_frames, missing_labels),

View file

@ -147,3 +147,15 @@ def test_unknown_process_frame_falls_back_to_process_frame_substrate() -> None:
(),
)
assert target == "substrate:process_frames"
def test_morphology_recommends_relation_gap_not_serving_migration() -> None:
target = recommend_migration_target(
(
"The reactor will decrease to 3/4 of its temperature. "
"If the current temperature is 80 degrees, what will the temperature be?"
),
(),
(),
)
assert target == "substrate:contract_gap:fraction_decrease"

View file

@ -12,3 +12,23 @@ def test_adequacy_report_smoke_does_not_require_gold_answers() -> None:
assert report["counts"]["frame_built"] == 1
assert "contract_runnable_count" in report["counts"]
assert report["per_case"][0]["current_verdict"] is None
def test_adequacy_reports_blockers_by_organ_and_combination() -> None:
report = build_report([
{
"case_id": "diagnostic-0393",
"question": (
"Yvonne brings a box of chocolates to school. Half have nuts and half do not. "
"The students eat 80% of the ones with nuts and eat half of the ones without nuts. "
"If there are 28 chocolates left, how many chocolates were in the box?"
),
}
])
per_case = report["per_case"][0]
assert "percent_partition" in per_case["blockers_by_organ"]
blockers = per_case["blockers_by_organ"]["percent_partition"]
assert "inverse_topology_unlicensed" in blockers
assert "original_whole_unbound" in blockers
assert "percent_partition" in report["blocker_combinations_by_organ"]

View file

@ -132,3 +132,45 @@ def test_question_target_is_explicitly_unbound_when_not_groundable() -> None:
frame = build_problem_frame("What is the answer?")
assert frame.bound_question_target is not None
assert not frame.bound_question_target.grounded
def test_decrease_to_fraction_binds_state_base_scale_and_delta_target() -> None:
text = (
"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?"
)
frame = build_problem_frame(text)
relation = next(item for item in frame.bound_relations if item.relation_type == "decrease_to_fraction")
assert {role.role for role in relation.roles} == {
"base_quantity",
"scale",
"state_entity",
"transition",
"unit",
}
assert frame.bound_question_target is not None
assert frame.bound_question_target.grounded
assert frame.bound_question_target.target_type == "difference"
assert frame.bound_question_target.target_operator == "difference"
assert frame.bound_question_target.target_state == "delta"
assert frame.bound_question_target.target_direction == "decrease"
def test_decrease_relation_preserves_exact_source_spans() -> None:
text = (
"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?"
)
frame = build_problem_frame(text)
relation = next(item for item in frame.bound_relations if item.relation_type == "decrease_to_fraction")
for span in relation.evidence_spans:
assert text[span.start:span.end] == span.text
assert any(span.text == "decrease to" for span in relation.evidence_spans)
assert any(span.text == "3/4" for span in relation.evidence_spans)
assert any(span.text == "84" for span in relation.evidence_spans)
assert any(span.text == "temperature" for span in relation.evidence_spans)

View file

@ -1,20 +1,109 @@
from generate.problem_frame_builder import build_problem_frame
from generate.problem_frame_contracts import assess_percent_partition
from generate.problem_frame_contracts import assess_contracts, assess_fraction_decrease, assess_percent_partition
def test_percent_partition_missing_bindings_is_not_runnable() -> None:
assessment = assess_percent_partition(build_problem_frame("Mia spent 50% of her money."))
assert not assessment.runnable
assert "grounded_whole_entity" in assessment.missing_bindings
assert "grounded_question_target" in assessment.missing_bindings
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?"
)
FRACTION_DECREASE_SIBLING = (
"In two hours, Cedar peak's temperature will decrease to 2/3 of its temperature. "
"If the current temperature of the peak is 60 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 = (
"Yun had 20 paperclips initially, but then lost 12. 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?"
)
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?"
)
UNEQUAL_PARTITION_CONFUSER = (
"A school has 100 students. 60 of the students are girls and 40 are boys. "
"20% of the girls have dogs and 10% of the boys have dogs. "
"How many students own dogs?"
)
INVERSE_REMAINING_CONFUSER = (
"Yvonne brings a box of chocolates to school. Half have nuts and half do not. "
"The students eat 80% of the ones with nuts and eat half of the ones without nuts. "
"If there are 28 chocolates left, how many chocolates were in the box?"
)
def test_tightly_grounded_percent_partition_is_diagnostically_runnable() -> None:
frame = build_problem_frame(
"There are 100 students. Half are girls. 30% of the girls own pets. "
"How many students own pets?"
)
assessment = assess_percent_partition(frame)
def test_fraction_decrease_contract_is_runnable_from_problemframe() -> None:
assessment = assess_fraction_decrease(build_problem_frame(FRACTION_DECREASE_CASE))
assert assessment.runnable
assert assessment.missing_bindings == ()
assert assessment.evidence_spans
def test_fraction_decrease_sibling_is_runnable() -> None:
assessment = assess_fraction_decrease(build_problem_frame(FRACTION_DECREASE_SIBLING))
assert assessment.runnable
def test_final_value_question_is_not_delta_runnable() -> None:
assessment = assess_fraction_decrease(build_problem_frame(FINAL_VALUE_CONFUSER))
assert not assessment.runnable
assert "delta_decrease_target_unbound" in assessment.missing_bindings
def test_affine_more_than_fraction_is_not_change_runnable() -> None:
assessments = assess_contracts(build_problem_frame(AFFINE_CONFUSER))
assert not any(item.candidate_organ == "fraction_decrease" for item in assessments)
def test_multiple_fraction_or_base_candidates_refuse_readiness() -> None:
assessments = assess_contracts(build_problem_frame(MULTIPLE_FRACTION_CONFUSER))
assert not any(item.candidate_organ == "fraction_decrease" and item.runnable for item in assessments)
def test_tightly_grounded_percent_partition_is_diagnostically_runnable() -> None:
assessment = assess_percent_partition(build_problem_frame(PERCENT_PARTITION_CASE))
assert assessment.runnable
assert assessment.missing_bindings == ()
assert assessment.evidence_spans
def test_percent_partition_requires_two_complementary_subgroups() -> None:
assessment = assess_percent_partition(build_problem_frame(ONE_SUBGROUP_CONFUSER))
assert not assessment.runnable
assert "partition_subgroups_not_distinct" in assessment.missing_bindings
assert "percent_subgroup_links_incomplete" in assessment.missing_bindings
def test_inverse_remaining_percent_case_is_not_runnable() -> None:
assessment = assess_percent_partition(build_problem_frame(INVERSE_REMAINING_CONFUSER))
assert not assessment.runnable
assert "inverse_topology_unlicensed" in assessment.missing_bindings
assert "original_whole_unbound" in assessment.missing_bindings
def test_unequal_partition_confuser_is_not_runnable() -> None:
assessment = assess_percent_partition(build_problem_frame(UNEQUAL_PARTITION_CONFUSER))
assert not assessment.runnable
assert "partition_subgroups_not_distinct" in assessment.missing_bindings or "percent_subgroup_links_incomplete" in assessment.missing_bindings

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from fractions import Fraction
import pytest
from generate.problem_frame import ProblemFrame, ProblemFrameBuilder, QuestionTarget
from generate.problem_frame import BoundQuestionTarget, ProblemFrame, ProblemFrameBuilder, QuestionTarget
from generate.kernel_facts import (
SourceSpan,
KernelProvenance,
@ -30,6 +30,22 @@ def test_question_target_validation() -> None:
QuestionTarget("target", "invalid_type")
def test_bound_question_target_rejects_illegal_delta_combinations() -> None:
span = SourceSpan("what will the temperature decrease by?", 0, 37)
with pytest.raises(ValueError, match="difference targets must bind a delta target_state"):
BoundQuestionTarget(
"difference",
"temperature",
"mention-0001",
"delta_quantity",
(span,),
target_operator="difference",
target_state="final",
target_direction="decrease",
)
def test_empty_problem_frame() -> None:
builder = ProblemFrameBuilder()
frame = builder.build()