feat(kernel): bind ProblemFrame relations and measure contract readiness (#831)

This commit is contained in:
Shay 2026-06-18 21:05:37 -07:00 committed by GitHub
parent 956523fd1d
commit 9fcc029a13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 863 additions and 34 deletions

110
HANDOFF-gpt55-2026-06-18.md Normal file
View file

@ -0,0 +1,110 @@
# HANDOFF — gpt55 — 2026-06-18
## Agent and Session
- **Agent:** gpt55
- **Date:** 2026-06-18
- **Reasoning effort used:** high
- **Grok Build mode used:** not applicable; Codex default mode
- **Session entry point:** Implement PR #831 brief: bind ProblemFrame relations and measure organ-contract readiness; write results to the requested handoff path.
## Smoke Suite + Bootstrap Status
```text
uv run python -m core.cli test --suite smoke -q
108 passed in 123.64s
```
The repository has no callable `core-bootstrap` skill in this session. The GPT55 checklist was completed manually: instructions, runtime contracts, recent handoff, dirty state, baseline smoke, scope, pre-edit sweep, and invariant statement.
## Modules Touched
| File | Change type | Summary |
|---|---|---|
| `generate/kernel_facts.py` | modified | Added frozen mention, binding, bound-role, and bound-relation primitives. |
| `generate/problem_frame.py` | modified | Added bound question target and canonical frame collections. |
| `generate/problem_frame_builder.py` | modified | Added conservative span-grounded extraction, binding, relation construction, target grounding, and centralized punctuation-safe surface matching. |
| `generate/problem_frame_contracts.py` | created | Added pure diagnostic contract assessments and gap-based migration recommendation. |
| `scripts/gsm8k_problem_frame_adequacy.py` | created | Added gold-independent adequacy reporting. |
| `scripts/gsm8k_substrate_morphology.py` | modified | Replaced trigger-priority recommendations with contract-gap evidence and centralized boundary recognition. |
| `tests/test_problem_frame_builder.py` | modified | Added mention/span, quantity/entity/unit binding, transfer relation, target, and determinism coverage. |
| `tests/test_problem_frame_contracts.py` | created | Added missing/runnable percent contract tests. |
| `tests/test_gsm8k_problem_frame_adequacy.py` | created | Added adequacy report smoke coverage. |
| `tests/test_gsm8k_morphology_missing_kernel_labels.py` | modified | Added punctuation boundary and contract-gap planner expectations. |
| `docs/analysis/problemframe-bindings-contract-readiness-2026-06-18.md` | created | Recorded model, metrics, deferral rationale, and migration conditions. |
| `HANDOFF-gpt55-2026-06-18.md` | created | Session continuity and verification record. |
## Invariants Verified
| Invariant | Check performed | Result | Notes |
|---|---|---|---|
| `versor_condition(F) < 1e-6` | Scope trace + smoke suite | preserved | No algebra, field state, operator, or normalization path changed. |
| versor_apply / cga_inner exactness | Import/call-site sweep | untouched | No algebra or recall imports added. |
| Normalization boundaries respected | Diff review | pass | No normalization/unitization/repair added. |
| No approximate recall | Diff review | pass | No vault or ranking changes. |
| No answer admission | Train/holdout serving probes | pass | Train 30 correct / 20 refused / 0 wrong; holdout 5 / 495 / 0. |
| No unreviewed mutation | Diff/status review | pass | Diagnostic-only; no teaching, pack, report, or sealed artifact mutation. |
| Exact source evidence | Focused tests | pass | Mention and contract evidence uses original source spans. |
## Subagent / Arena Reconciliation
- Number of subagents spawned: 0
- Each subagent independently verified versor closure: not applicable
- Reconciliation: not applicable
## Tests Run
```text
tests/test_problem_frame_builder.py: 12 passed
tests/test_gsm8k_morphology_missing_kernel_labels.py: 8 passed
tests/test_kernel_no_new_legacy_derivation_surfaces.py: 2 passed
tests/test_problem_frame_contracts.py: 2 passed
tests/test_gsm8k_problem_frame_adequacy.py: 1 passed
core test --suite smoke -q: 108 passed
git diff --check: pass
```
Safety probes:
```text
train_sample: correct=30 refused=20 wrong=0; wrong_ids=[]
holdout_dev: correct=5 refused=495 wrong=0; wrong_ids=[]
```
Adequacy after implementation:
```text
train 50: entity=50 quantity_binding=46 relation=16 bound_target=42 candidates=42 runnable=1
holdout 500: entity=494 quantity_binding=452 relation=124 bound_target=402 candidates=423 runnable=1
```
## Open Tasks / Next Session Entry Point
1. Review the conservative local grammar against adequacy gap distributions; widen only with confuser tests and exact spans.
2. Bind container and labor-rate roles before allowing their contracts to become runnable.
3. Do not migrate `percent_partition` until runnable coverage and no-fallback organ consumption are proven beyond case 0046.
## Known Hazards / Do Not Touch
- `contract_runnable_count=1` in each corpus is intentionally conservative; do not replace it with trigger presence.
- Case `gsm8k-train-sample-v1-0046` is diagnostic evidence only, not serving authorization.
- Do not add raw-text fallback inside derivation organs or mutate `report.json`/sealed artifacts.
## Architectural Decisions Made This Session
- The canonical `ProblemFrame` owns mentions, bindings, relations, and target state; no parallel diagnostic graph.
- `subgroup_partition` and `percent_of` are distinct, and readiness requires matching subgroup referents.
- Contract assessment is a pure projection over typed frame evidence.
- Punctuation-safe token boundary matching is centralized in the ProblemFrame builder and reused by morphology.
## What Must Not Be Forgotten
`contract_runnable_count`, not process-trigger presence, is the honest migration-readiness metric. Serving remains unchanged.
## Skills Used This Session
- core-bootstrap: unavailable; checklist performed manually
- versor-coherence-guardian: unavailable; scope + smoke verification performed manually
- pre-edit-sweep: unavailable; import/caller/eval/test sweep performed manually
- claim-proposal-guardian: not applicable; no claim or proposal mutation
- Other: none

View file

@ -0,0 +1,107 @@
# ProblemFrame Bindings and Contract Readiness — 2026-06-18
## Limitation
PR #830 recognized scalar, unit, hazard, and process-frame presence but left
semantic roles declarative. On train 50 / holdout 500, actor/object bindings,
bound question targets, and runnable organ contracts were all zero. A process
trigger therefore could not prove that an organ had the facts it required.
## Map
Three directions were considered:
1. Extend the canonical `ProblemFrame` with span-grounded mentions, bindings,
bound relations, and a bound question target.
2. Build a parallel diagnostic graph beside `ProblemFrame`.
3. Infer readiness in reporting scripts from raw text.
Direction 1 was selected. It preserves one source of structural truth and lets
contract assessment operate only on typed frame evidence. Directions 2 and 3
would permit disagreement or raw-text fallback at the readiness boundary.
## Binding Model
`GroundedMention` retains kind, exact `SourceSpan`, original source text, and an
optional substrate fact ID. `MentionBinding` currently represents only the two
load-bearing edges: quantity-to-entity and quantity-to-unit. IDs and collection
order derive from source position and stable tie-breaks.
The builder recognizes a deliberately narrow set of local forms. It does not
derive answers, inspect case IDs, or introduce organ-local parsers.
## Bound Relation Model
`BoundRole` points a declared semantic role at a grounded mention or fact.
`BoundRelation` groups those role edges with their evidence spans. The first
diagnostic relations are:
- `transfer`: agent, patient, quantity, object;
- `subgroup_partition`: whole, part, fractional scale;
- `percent_of`: whole, part, percent scale.
Subgroup and percent relations are distinct so readiness cannot be fabricated
by pooling role names from unrelated candidates.
## Question Target Model
`BoundQuestionTarget` records the requested surface, target kind, evidence, and
the grounded target mention. An interrogative that cannot be grounded remains
an explicit target with `target_mention_id=None`; absence and unresolved state
are not conflated.
## Contract Assessment Model
`ContractAssessment` is a diagnostic projection with candidate organ, missing
bindings, unresolved hazards, evidence spans, explanation, and `runnable`.
`percent_partition` is runnable only when:
- a whole and subgroup are grounded by a subgroup relation;
- a percent relation refers to that same subgroup surface;
- the question target is grounded;
- no relevant blocking hazard remains.
Container and temporal contracts are emitted as explicit future-facing gaps.
Assessment does not admit an organ to serving.
## Adequacy Metrics
The new report reads committed case text and optional existing verdict metadata.
Gold answers are not used to synthesize bindings or contracts.
| Metric | Before train 50 | After train 50 | Before holdout 500 | After holdout 500 |
|---|---:|---:|---:|---:|
| frame built | 50 | 50 | 500 | 500 |
| scalar present | 47 | 47 | 470 | 470 |
| unit present | 21 | 21 | 202 | 202 |
| entity mention present | 0 | 50 | 0 | 494 |
| quantity binding present | 0 | 46 | 0 | 452 |
| bound process relation present | 0 | 16 | 0 | 124 |
| bound question target present | 0 | 42 | 0 | 402 |
| contract candidates | 0 | 42 | 0 | 423 |
| `contract_runnable_count` | 0 | 1 | 0 | 1 |
`contract_runnable_count` is the honest readiness metric. Trigger presence is
not readiness.
## Why `percent_partition` Remains Deferred
Case `gsm8k-train-sample-v1-0046` now has five quantity bindings, four bound
relations, a grounded question target, and a runnable diagnostic contract. That
is progress, not a serving migration. Only 1/42 train candidates and 1/423
holdout candidates meet the conservative contract, so broad admission would
still require fallback or overclaim coverage.
Migration becomes safe when the target corpus has adequate runnable coverage,
the remaining gap taxonomy is explicitly addressed, an organ consumes only
`ProblemFrame` evidence, and the serving safety lanes remain wrong-free. This
change intentionally leaves serving, answer admission, `report.json`, and
sealed artifacts unchanged.
## Masterstroke
Readiness is represented as a conjugate of construction: the builder propagates
source evidence into typed relations, while contract assessment exposes exactly
where that propagation fails to close. The design makes a nominal frame call
insufficient; an organ is runnable only when its intrinsic role geometry is
actually bound.

View file

@ -19,7 +19,7 @@ from __future__ import annotations
from dataclasses import dataclass
from fractions import Fraction
from typing import Union
from typing import Literal, Union
# ---------------------------------------------------------------------------
# Provenance kinds — closed set per Tranche 1 brief.
@ -207,6 +207,55 @@ class CandidateRelation:
hazards: tuple[KernelHazard, ...] = ()
# ---------------------------------------------------------------------------
# Span-grounded mention and binding primitives.
# ---------------------------------------------------------------------------
MentionKind = Literal["entity", "actor", "object", "quantity", "unit"]
BindingKind = Literal["quantity_entity", "quantity_unit"]
@dataclass(frozen=True, slots=True)
class GroundedMention:
"""A deterministic, source-grounded mention; never a derived answer."""
mention_id: str
kind: MentionKind
surface: str
span: SourceSpan
fact_id: str | None = None
@dataclass(frozen=True, slots=True)
class MentionBinding:
"""A typed edge between two grounded mentions."""
binding_id: str
binding_type: BindingKind
source_mention_id: str
target_mention_id: str
evidence_spans: tuple[SourceSpan, ...]
@dataclass(frozen=True, slots=True)
class BoundRole:
"""A declared relation role bound to a mention or substrate fact."""
role: str
target_id: str
target_kind: str
evidence_spans: tuple[SourceSpan, ...]
@dataclass(frozen=True, slots=True)
class BoundRelation:
"""A candidate relation whose roles have explicit grounded referents."""
relation_id: str
relation_type: str
roles: tuple[BoundRole, ...]
evidence_spans: tuple[SourceSpan, ...]
# ---------------------------------------------------------------------------
# Substrate fact — the canonical union wrapper.
# ---------------------------------------------------------------------------

View file

@ -13,11 +13,15 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from generate.kernel_facts import (
BoundRelation,
GroundedScalar,
GroundedMention,
GroundedUnit,
MentionBinding,
CandidateRelation,
KernelHazard,
KernelProvenance,
SourceSpan,
)
from language_packs.scalar_equivalence import ScalarCandidate
from generate.process_frames import ProcessFrame
@ -39,6 +43,21 @@ class QuestionTarget:
)
@dataclass(frozen=True, slots=True)
class BoundQuestionTarget:
"""Question target grounded to a mention, or explicitly unresolved."""
target_type: str
requested_surface: str
target_mention_id: str | None
unknown_slot: str
evidence_spans: tuple[SourceSpan, ...]
@property
def grounded(self) -> bool:
return self.target_mention_id is not None
@dataclass(frozen=True, slots=True)
class ProblemFrame:
"""Immutable target representation of a mathematical word problem.
@ -56,6 +75,10 @@ class ProblemFrame:
question_target: QuestionTarget | None
hazards: tuple[KernelHazard, ...]
provenance: tuple[KernelProvenance, ...]
mentions: tuple[GroundedMention, ...] = ()
bindings: tuple[MentionBinding, ...] = ()
bound_relations: tuple[BoundRelation, ...] = ()
bound_question_target: BoundQuestionTarget | None = None
class ProblemFrameBuilder:
@ -72,6 +95,10 @@ class ProblemFrameBuilder:
self._question_target: QuestionTarget | None = None
self._hazards: list[KernelHazard] = []
self._provenance: list[KernelProvenance] = []
self._mentions: list[GroundedMention] = []
self._bindings: list[MentionBinding] = []
self._bound_relations: list[BoundRelation] = []
self._bound_question_target: BoundQuestionTarget | None = None
def add_quantity(self, scalar: GroundedScalar) -> None:
"""Add a GroundedScalar to the frame, collecting hazards and provenance."""
@ -123,6 +150,18 @@ class ProblemFrameBuilder:
"""Add a provenance record directly to the frame."""
self._provenance.append(provenance)
def add_mention(self, mention: GroundedMention) -> None:
self._mentions.append(mention)
def add_binding(self, binding: MentionBinding) -> None:
self._bindings.append(binding)
def add_bound_relation(self, relation: BoundRelation) -> None:
self._bound_relations.append(relation)
def set_bound_question_target(self, target: BoundQuestionTarget) -> None:
self._bound_question_target = target
def build(self) -> ProblemFrame:
"""Produce the immutable ProblemFrame."""
return ProblemFrame(
@ -136,4 +175,8 @@ class ProblemFrameBuilder:
question_target=self._question_target,
hazards=tuple(self._hazards),
provenance=tuple(self._provenance),
mentions=tuple(self._mentions),
bindings=tuple(self._bindings),
bound_relations=tuple(self._bound_relations),
bound_question_target=self._bound_question_target,
)

View file

@ -16,15 +16,24 @@ import re
from fractions import Fraction
from generate.kernel_facts import (
BoundRelation,
BoundRole,
CandidateRelation,
GroundedMention,
GroundedScalar,
GroundedUnit,
KernelHazard,
KernelProvenance,
MentionBinding,
RelationRole,
SourceSpan,
)
from generate.problem_frame import ProblemFrame, ProblemFrameBuilder, QuestionTarget
from generate.problem_frame import (
BoundQuestionTarget,
ProblemFrame,
ProblemFrameBuilder,
QuestionTarget,
)
from generate.process_frames import ProcessFrame, all_frames
from language_packs.ambiguity_hazards import (
AmbiguityHazard,
@ -51,15 +60,13 @@ _ORDINAL_SUFFIX_RE: re.Pattern[str] = re.compile(
)
def _surface_in_text(surface: str, text_lower: str) -> bool:
token = surface.lower()
padded = f" {text_lower} "
return (
f" {token} " in padded
or text_lower.startswith(f"{token} ")
or text_lower.endswith(f" {token}")
or text_lower == token
)
def surface_in_text(surface: str, text: str) -> bool:
"""Match a registered surface at lexical, including punctuation, boundaries."""
return re.search(
rf"(?<![\w]){re.escape(surface)}(?![\w])",
text,
flags=re.IGNORECASE,
) is not None
def _hazard_to_kernel(hazard: AmbiguityHazard) -> KernelHazard:
@ -111,7 +118,7 @@ def _extract_hazards(text: str) -> tuple[KernelHazard, ...]:
seen: set[str] = set()
for surface in all_registered_surfaces():
if not _surface_in_text(surface, text_lower):
if not surface_in_text(surface, text_lower):
continue
for hazard in lookup_hazards(surface):
if hazard.hazard_id in seen:
@ -159,12 +166,14 @@ def _filter_scalar_candidates(
def _trigger_span(text: str, trigger: str) -> SourceSpan | None:
text_lower = text.lower()
trigger_lower = trigger.lower()
idx = text_lower.find(trigger_lower)
if idx < 0:
match = re.search(
rf"(?<![\w]){re.escape(trigger)}(?![\w])",
text,
flags=re.IGNORECASE,
)
if match is None:
return None
return SourceSpan(text[idx:idx + len(trigger_lower)], idx, idx + len(trigger_lower))
return SourceSpan(text[match.start():match.end()], match.start(), match.end())
def _extract_process_frame_candidates(text: str) -> tuple[ProcessFrame, ...]:
@ -173,7 +182,7 @@ def _extract_process_frame_candidates(text: str) -> tuple[ProcessFrame, ...]:
for frame in all_frames():
for trigger in frame.trigger_surfaces:
if _surface_in_text(trigger, text_lower):
if surface_in_text(trigger, text_lower):
matched[frame.name] = frame
break
@ -268,6 +277,210 @@ def _detect_question_target(text: str) -> QuestionTarget | None:
return None
_ENTITY_AFTER_QUANTITY_RE = re.compile(
r"(?P<quantity>\d+(?:\.\d+)?\s*%?)\s+(?:of\s+(?:the\s+)?)?"
r"(?P<entity>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
_FRACTION_ENTITY_RE = re.compile(
r"\b(?P<quantity>half|third|quarter)\b\s+(?:of\s+(?:the\s+)?|are\s+|the\s+)?"
r"(?P<entity>[A-Za-z][A-Za-z'-]*)",
re.IGNORECASE,
)
_QUESTION_ENTITY_RE = re.compile(
r"\bhow\s+(?:many|much)\s+(?:more\s+)?(?P<entity>[A-Za-z][A-Za-z'-]*)",
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"
)
_TRANSFER_RE = re.compile(
r"\b(?P<agent>[A-Z][A-Za-z'-]*)\s+(?:gave|gives|give|handed|passed)\s+"
r"(?P<patient>[A-Z][A-Za-z'-]*)\s+"
r"(?P<quantity>\d+(?:\.\d+)?)\s+(?P<object>[A-Za-z][A-Za-z'-]*)",
)
def _extract_mentions(
text: str,
quantities: tuple[GroundedScalar, ...],
units: tuple[GroundedUnit, ...],
) -> tuple[GroundedMention, ...]:
records: dict[tuple[str, int, int], GroundedMention] = {}
def add(kind: str, start: int, end: int, *, fact_id: str | None = None) -> None:
key = (kind, start, end)
if key in records:
return
records[key] = GroundedMention(
mention_id="", kind=kind, surface=text[start:end],
span=SourceSpan(text[start:end], start, end), fact_id=fact_id,
)
for quantity in quantities:
span = quantity.provenance.source_spans[0]
add("quantity", span.start, span.end, fact_id=quantity.fact_id)
for unit in units:
span = unit.provenance.source_spans[0]
add("unit", span.start, span.end, fact_id=unit.fact_id)
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 _ACTOR_VERB_RE.finditer(text):
add("actor", match.start("actor"), match.end("actor"))
for match in _TRANSFER_RE.finditer(text):
add("actor", match.start("agent"), match.end("agent"))
add("actor", match.start("patient"), match.end("patient"))
add("object", match.start("object"), match.end("object"))
ordered = sorted(
records.values(),
key=lambda m: (m.span.start, m.span.end, m.kind, m.surface.lower()),
)
return tuple(
GroundedMention(
mention_id=f"mention-{index:04d}", kind=m.kind, surface=m.surface,
span=m.span, fact_id=m.fact_id,
)
for index, m in enumerate(ordered)
)
def _extract_bindings(
text: str,
mentions: tuple[GroundedMention, ...],
) -> tuple[MentionBinding, ...]:
by_span_kind = {(m.span.start, m.span.end, m.kind): m for m in mentions}
quantities = [m for m in mentions if m.kind == "quantity"]
bindings: list[MentionBinding] = []
seen: set[tuple[str, str, str]] = set()
def bind(binding_type: str, source: GroundedMention, target: GroundedMention) -> None:
key = (binding_type, source.mention_id, target.mention_id)
if key in seen:
return
seen.add(key)
bindings.append(MentionBinding(
binding_id="", binding_type=binding_type,
source_mention_id=source.mention_id, target_mention_id=target.mention_id,
evidence_spans=(source.span, target.span),
))
for pattern in (_ENTITY_AFTER_QUANTITY_RE, _FRACTION_ENTITY_RE):
for match in pattern.finditer(text):
entity = by_span_kind.get((match.start("entity"), match.end("entity"), "object"))
if entity is None:
continue
candidates = [q for q in quantities if q.span.start == match.start("quantity")]
if candidates:
bind("quantity_entity", candidates[0], entity)
units = [m for m in mentions if m.kind == "unit"]
for quantity in quantities:
following = [
unit
for unit in units
if unit.span.start >= quantity.span.end
and not text[quantity.span.end:unit.span.start].strip()
]
if following:
bind("quantity_unit", quantity, min(following, key=lambda u: u.span.start))
ordered = sorted(bindings, key=lambda b: (b.evidence_spans[0].start, b.binding_type, b.target_mention_id))
return tuple(MentionBinding(
binding_id=f"binding-{index:04d}", binding_type=b.binding_type,
source_mention_id=b.source_mention_id, target_mention_id=b.target_mention_id,
evidence_spans=b.evidence_spans,
) for index, b in enumerate(ordered))
def _bound_relations(
text: str,
mentions: tuple[GroundedMention, ...],
bindings: tuple[MentionBinding, ...],
) -> tuple[BoundRelation, ...]:
by_id = {m.mention_id: m for m in mentions}
relations: list[BoundRelation] = []
quantity_entity = [b for b in bindings if b.binding_type == "quantity_entity"]
whole = next(
(
binding
for binding in quantity_entity
if "%" not in by_id[binding.source_mention_id].surface
and by_id[binding.source_mention_id].surface.lower()
not in {"half", "third", "quarter"}
),
None,
)
for binding in quantity_entity:
quantity = by_id[binding.source_mention_id]
part = by_id[binding.target_mention_id]
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("scale", quantity.mention_id, quantity.kind, (quantity.span,)),
]
if whole is not None:
whole_entity = by_id[whole.target_mention_id]
roles.insert(0, BoundRole("whole", whole_entity.mention_id, whole_entity.kind, (whole_entity.span,)))
relation_type = "percent_of" if "%" in quantity.surface else "subgroup_partition"
relations.append(BoundRelation(
relation_id="", relation_type=relation_type, 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)
agent = at("agent", "actor")
patient = at("patient", "actor")
quantity = at("quantity", "quantity")
obj = at("object", "object")
if all((agent, patient, quantity, obj)):
assert agent and patient and quantity and obj
roles = tuple(
BoundRole(name, mention.mention_id, mention.kind, (mention.span,))
for name, mention in (
("agent", agent), ("patient", patient),
("quantity", quantity), ("object", obj),
)
)
relations.append(BoundRelation(
"", "transfer", roles,
tuple(m.span for m in (agent, patient, quantity, obj)),
))
relations.sort(key=lambda r: (r.evidence_spans[0].start, r.relation_type))
return tuple(
BoundRelation(
f"bound-rel-{index:04d}", relation.relation_type,
relation.roles, relation.evidence_spans,
)
for index, relation in enumerate(relations)
)
def _bound_question_target(text: str, mentions: tuple[GroundedMention, ...]) -> BoundQuestionTarget | None:
question = _QUESTION_ENTITY_RE.search(text)
if question is None:
if "?" not in text:
return None
qmark = text.index("?")
return BoundQuestionTarget(
"unknown", "?", None, "unresolved",
(SourceSpan("?", qmark, qmark + 1),),
)
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"
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,),
)
def build_problem_frame(problem_text: str) -> ProblemFrame:
"""Build a substrate-backed ProblemFrame from raw problem text.
@ -280,12 +493,15 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
for scalar in scalars:
builder.add_scalar(scalar)
grounded_quantities: list[GroundedScalar] = []
for index, scalar in enumerate(scalars):
grounded = _scalar_to_grounded(scalar, problem_text, index)
if grounded is not None:
builder.add_quantity(grounded)
grounded_quantities.append(grounded)
for unit in _extract_unit_candidates(problem_text):
units = _extract_unit_candidates(problem_text)
for unit in units:
builder.add_unit(unit)
for hazard in _extract_hazards(problem_text):
@ -302,6 +518,22 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
if question_target is not None:
builder.set_question_target(question_target)
mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units)
bindings = _extract_bindings(problem_text, mentions)
for mention in mentions:
builder.add_mention(mention)
if mention.kind == "actor":
builder.add_actor(mention.surface)
elif mention.kind == "object":
builder.add_object(mention.surface)
for binding in bindings:
builder.add_binding(binding)
for relation in _bound_relations(problem_text, mentions, bindings):
builder.add_bound_relation(relation)
bound_target = _bound_question_target(problem_text, mentions)
if bound_target is not None:
builder.set_bound_question_target(bound_target)
return builder.build()

View file

@ -0,0 +1,132 @@
"""Diagnostic organ-contract readiness derived only from ProblemFrame evidence."""
from __future__ import annotations
from dataclasses import dataclass
from generate.kernel_facts import BoundRelation, SourceSpan
from generate.problem_frame import ProblemFrame
@dataclass(frozen=True, slots=True)
class ContractAssessment:
candidate_organ: str
missing_bindings: tuple[str, ...]
unresolved_hazards: tuple[str, ...]
runnable: bool
explanation: str
evidence_spans: tuple[SourceSpan, ...]
def _roles(frame: ProblemFrame, relation_type: str) -> set[str]:
return {
role.role
for relation in frame.bound_relations
if relation.relation_type == relation_type
for role in relation.roles
}
def _evidence(frame: ProblemFrame, relation_type: str) -> tuple[SourceSpan, ...]:
spans = {
(span.start, span.end, span.text): span
for relation in frame.bound_relations
if relation.relation_type == relation_type
for span in relation.evidence_spans
}
if frame.bound_question_target is not None:
for span in frame.bound_question_target.evidence_spans:
spans[(span.start, span.end, span.text)] = span
return tuple(spans[key] for key in sorted(spans))
def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment:
mentions = {mention.mention_id: mention for mention in frame.mentions}
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"]
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:
continue
subgroup_surface = mentions[subgroup_part].surface.lower()
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:
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")
question_target = frame.bound_question_target
if question_target is None or not question_target.grounded:
missing.append("grounded_question_target")
unresolved: set[str] = set()
categories = {hazard.category for hazard in frame.hazards}
if "grounded_whole_entity" in missing 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:
unresolved.add("percent_change_vs_percent_of")
runnable = not missing and not unresolved
return ContractAssessment(
candidate_organ="percent_partition",
missing_bindings=tuple(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)))
),
evidence_spans=tuple(sorted(
{
(span.start, span.end, span.text): span
for pair in linked_pairs
for relation in pair
for span in relation.evidence_spans
}.values(),
key=lambda span: (span.start, span.end, span.text),
)) + (() if question_target is None else question_target.evidence_spans),
)
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 frame_names & {"partition", "consumption"}:
results.append(assess_percent_partition(frame))
if "container_packing" in frame_names and frame.bound_question_target is not None:
roles = _roles(frame, "container_packing")
missing = tuple(name for name in ("container", "content", "count_per") if name not in roles)
results.append(ContractAssessment(
"nested_fraction_remainder_total", missing, (), not missing,
"container contract grounded" if not missing else "missing container bindings: " + ", ".join(missing),
_evidence(frame, "container_packing"),
))
if "labor_rate" in frame_names:
roles = _roles(frame, "labor_rate")
missing = tuple(name for name in ("worker", "rate", "duration") if name not in roles)
results.append(ContractAssessment(
"temporal_tariff", missing, (), not missing,
"temporal tariff contract grounded" if not missing else "missing tariff bindings: " + ", ".join(missing),
_evidence(frame, "labor_rate"),
))
return tuple(sorted(results, key=lambda item: item.candidate_organ))
def recommended_migration_target(assessments: tuple[ContractAssessment, ...]) -> str:
runnable = [item.candidate_organ for item in assessments if item.runnable]
if runnable:
return sorted(runnable)[0]
if assessments:
best = min(assessments, key=lambda item: (len(item.missing_bindings) + len(item.unresolved_hazards), item.candidate_organ))
return f"substrate:contract_gap:{best.candidate_organ}"
return "substrate:problem_frame_builder"

View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Report ProblemFrame binding and organ-contract adequacy without solving."""
from __future__ import annotations
import argparse
import json
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
from generate.problem_frame_builder import build_problem_frame
from generate.problem_frame_contracts import assess_contracts, recommended_migration_target
def assess_case(case: Mapping[str, Any], *, current_verdict: str | None = None) -> dict[str, Any]:
text = str(case.get("question") or case.get("problem") or case.get("problem_text") or "")
case_id = str(case.get("case_id") or case.get("id") or "unknown")
frame = build_problem_frame(text)
contracts = assess_contracts(frame)
return {
"case_id": case_id,
"current_verdict": current_verdict,
"frame_built": True,
"scalar_count": len(frame.quantities),
"unit_count": len(frame.units),
"entity_mention_count": sum(m.kind in {"entity", "actor", "object"} for m in frame.mentions),
"quantity_binding_count": sum(b.binding_type == "quantity_entity" for b in frame.bindings),
"process_relation_count": len(frame.bound_relations),
"bound_question_target_present": bool(frame.bound_question_target and frame.bound_question_target.grounded),
"candidate_organ_contracts": [item.candidate_organ for item in contracts],
"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}),
"recommended_next_migration_target": recommended_migration_target(contracts),
}
def build_report(cases: Iterable[Mapping[str, Any]], *, verdicts: Mapping[str, str] | None = None) -> dict[str, Any]:
verdicts = verdicts or {}
per_case = [
assess_case(case, current_verdict=verdicts.get(str(case.get("case_id") or case.get("id") or "unknown")))
for case in cases
]
return {
"schema_version": 1,
"case_count": len(per_case),
"counts": {
"frame_built": sum(row["frame_built"] for row in per_case),
"scalar_present": sum(row["scalar_count"] > 0 for row in per_case),
"unit_present": sum(row["unit_count"] > 0 for row in per_case),
"entity_mention_present": sum(row["entity_mention_count"] > 0 for row in per_case),
"quantity_binding_present": sum(row["quantity_binding_count"] > 0 for row in per_case),
"process_relation_present": sum(row["process_relation_count"] > 0 for row in per_case),
"bound_question_target_present": sum(row["bound_question_target_present"] for row in per_case),
"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),
},
"per_case": per_case,
}
def _load_jsonl(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cases", type=Path, required=True)
parser.add_argument("--limit", type=int)
args = parser.parse_args(argv)
cases = _load_jsonl(args.cases)
if args.limit is not None:
cases = cases[:args.limit]
print(json.dumps(build_report(cases), indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -14,8 +14,10 @@ from generate.problem_frame_builder import (
recognized_process_frame_names,
recognized_scalar_surfaces,
recognized_unit_surfaces,
surface_in_text,
)
from generate.process_frames import lookup_frame
from generate.problem_frame_contracts import assess_contracts, recommended_migration_target as contract_target
from generate.process_frames import frame_by_name, lookup_frame
from language_packs.loader import lookup_container
from language_packs.scalar_equivalence import list_unsupported_surfaces
from language_packs.unit_dimensions import classify_dimension
@ -50,15 +52,13 @@ _STOPWORDS = {
def _surface_in_text(surface: str, text_lower: str) -> bool:
padded = f" {text_lower} "
token = surface.lower()
return f" {token} " in padded or text_lower.startswith(f"{token} ") or text_lower.endswith(f" {token}") or text_lower == token
return surface_in_text(surface, text_lower)
def _registered_frame_present(text_lower: str, expected: set[str]) -> bool:
for frame_name in expected:
for frame in lookup_frame(frame_name):
if any(_surface_in_text(trigger, text_lower) for trigger in frame.trigger_surfaces):
frame = frame_by_name(frame_name)
if frame is not None and any(_surface_in_text(trigger, text_lower) for trigger in frame.trigger_surfaces):
return True
for trigger in text_lower.split():
if any(frame.name in expected for frame in lookup_frame(trigger)):
@ -131,10 +131,9 @@ def _target_for_process_frames(process_frames: tuple[str, ...]) -> str | None:
def recommend_migration_target(problem_text: str, process_frames: tuple[str, ...], missing_labels: tuple[str, ...]) -> str:
lowered = problem_text.lower()
if "%" in problem_text and ("half" in lowered or "partition" in process_frames or "consumption" in process_frames):
return "percent_partition"
if "other half" in lowered and "%" in problem_text:
return "percent_partition"
assessments = assess_contracts(build_problem_frame(problem_text))
if assessments:
return contract_target(assessments)
if "missing_scalar_equivalence" in missing_labels:
return "substrate:scalar_equivalence"
if "missing_unit_dimension" in missing_labels:
@ -159,6 +158,7 @@ def plan_substrate_case(*, case_id: str, problem_text: str, current_verdict: str
frame = build_problem_frame(problem_text)
missing_labels = classify_missing_substrate(problem_text)
process_frames = recognized_process_frame_names(frame)
assessments = assess_contracts(frame)
return {
"case_id": case_id,
"current_verdict": current_verdict,
@ -166,9 +166,17 @@ def plan_substrate_case(*, case_id: str, problem_text: str, current_verdict: str
"recognized_units": recognized_unit_surfaces(frame),
"recognized_process_frames": process_frames,
"recognized_hazards": recognized_hazard_ids(frame),
"entity_mention_count": sum(m.kind in {"entity", "actor", "object"} for m in frame.mentions),
"quantity_binding_count": sum(b.binding_type == "quantity_entity" for b in frame.bindings),
"bound_process_relation_count": len(frame.bound_relations),
"bound_question_target_present": bool(frame.bound_question_target and frame.bound_question_target.grounded),
"candidate_organ_contracts": tuple(a.candidate_organ for a in assessments),
"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})),
"missing_substrate_labels": missing_labels,
"legacy_parser_dependency": _legacy_parser_dependency(problem_text, process_frames, missing_labels),
"recommended_migration_target": recommend_migration_target(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

@ -100,7 +100,7 @@ def test_planner_v2_recognizes_substrate_without_solving() -> None:
assert "consumption" in record["recognized_process_frames"]
assert record["recognized_hazards"]
assert isinstance(record["legacy_parser_dependency"], tuple)
assert record["recommended_migration_target"] == "percent_partition"
assert record["recommended_migration_target"] == "substrate:contract_gap:percent_partition"
assert record["recommended_migration_target"] not in _RAW_PROCESS_FRAME_NAMES
@ -115,7 +115,13 @@ def test_planner_v2_recommends_percent_partition_for_half_percent_split() -> Non
("partition", "consumption"),
classify_missing_substrate(text),
)
assert target == "percent_partition"
assert target in {"percent_partition", "substrate:contract_gap:percent_partition"}
def test_punctuation_boundary_registered_container() -> None:
assert "missing_container_frame" not in classify_missing_substrate(
"There are 10 bloops in the box."
)
def test_planner_fallback_never_returns_raw_process_frame_name() -> None:

View file

@ -0,0 +1,14 @@
from scripts.gsm8k_problem_frame_adequacy import build_report
def test_adequacy_report_smoke_does_not_require_gold_answers() -> None:
report = build_report([
{
"case_id": "diagnostic-1",
"question": "There are 100 students. Half are girls. How many students are girls?",
}
])
assert report["case_count"] == 1
assert report["counts"]["frame_built"] == 1
assert "contract_runnable_count" in report["counts"]
assert report["per_case"][0]["current_verdict"] is None

View file

@ -103,3 +103,32 @@ def test_deterministic_ordering_across_repeated_runs() -> None:
assert frame_a.process_frames == frame_b.process_frames
assert frame_a.hazards == frame_b.hazards
assert frame_a.candidate_relations == frame_b.candidate_relations
assert frame_a.mentions == frame_b.mentions
assert frame_a.bindings == frame_b.bindings
assert frame_a.bound_relations == frame_b.bound_relations
def test_mentions_bind_quantities_and_units_with_exact_spans() -> None:
text = "A runner traveled 5 miles in 2 hours. How many miles?"
frame = build_problem_frame(text)
assert any(binding.binding_type == "quantity_entity" for binding in frame.bindings)
assert any(binding.binding_type == "quantity_unit" for binding in frame.bindings)
for mention in frame.mentions:
assert text[mention.span.start:mention.span.end] == mention.span.text
def test_transfer_roles_and_question_target_are_bound() -> None:
frame = build_problem_frame(
"Tom gave Ana 3 marbles. How many marbles does Ana have?"
)
relation = next(item for item in frame.bound_relations if item.relation_type == "transfer")
assert {role.role for role in relation.roles} == {"agent", "patient", "quantity", "object"}
assert frame.bound_question_target is not None
assert frame.bound_question_target.grounded
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

View file

@ -0,0 +1,20 @@
from generate.problem_frame_builder import build_problem_frame
from generate.problem_frame_contracts import 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
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)
assert assessment.runnable
assert assessment.missing_bindings == ()
assert assessment.evidence_spans