feat(kernel): implement ProblemFrame proportional-change closure (PR slice 1) (#835)
This commit is contained in:
parent
b8330477a4
commit
0a168bfb99
11 changed files with 759 additions and 134 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue