fix(kernel): ground scalar spans and tighten morphology labels

Add extract_scalar_candidates() with exact source spans and problem_text
provenance while keeping canonicalize_scalar() as the detached pack helper.
Morphology labels now emit missing_* only when substrate frame/unit lookups
actually fail, not on mere trigger-surface presence.
This commit is contained in:
Shay 2026-06-18 18:36:19 -07:00
parent 12a53648f6
commit 1e4fcff102
5 changed files with 442 additions and 54 deletions

View file

@ -28,7 +28,28 @@ Seven new Python modules have been introduced to establish the substrate layer:
## 4. Scalar Equivalence Coverage ## 4. Scalar Equivalence Coverage
Exposed via `language_packs/scalar_equivalence.py`: ### Canonicalize vs grounded extraction
`language_packs/scalar_equivalence.py` exposes two complementary APIs:
- **`canonicalize_scalar(surface)`** — pack-level helper for detached surface strings.
Returns a `ScalarCandidate` with canonical `Fraction`, source kind, entry id, and
hazards. Provenance and span fields remain `None` (no problem-text grounding).
- **`extract_scalar_candidates(text)`** — text-level extraction for ProblemFrame
substrate facts. Every emitted candidate carries:
- `source_surface` — exact substring from the original text
- `source_span``(start, end)` character offsets (Python slice semantics)
- `provenance_kind = "problem_text"`
- `canonical` — exact `Fraction`
- `source` — scalar resolution kind (`fraction_word`, `decimal`, etc.)
- `hazards` — ambiguity hazard IDs
Pack/world/derived values (`classify_dimension`, detached `canonicalize_scalar`)
do not masquerade as `problem_text`. Multiple scalars are emitted in deterministic
left-to-right span order. Unsupported forms (`.5`, `1 / 2`) are omitted from
extraction and flagged separately by the morphology atlas.
Exposed scalar surfaces via the facade:
- **Word forms:** `half`, `one half`, `one-half`, `third`, `one third`, `two thirds`, `quarter`, `one quarter`, `three quarters`. - **Word forms:** `half`, `one half`, `one-half`, `third`, `one third`, `two thirds`, `quarter`, `one quarter`, `three quarters`.
- **Symbols:** Unicode symbols (`½`, `¼`, `¾`, `⅓`, `⅔`). - **Symbols:** Unicode symbols (`½`, `¼`, `¾`, `⅓`, `⅔`).
- **Mixed numbers / slash forms:** `1/2`, `3/4`, `1 1/2`. - **Mixed numbers / slash forms:** `1/2`, `3/4`, `1 1/2`.
@ -97,6 +118,28 @@ Exposed via `scripts/gsm8k_substrate_morphology.py`:
`missing_scalar_equivalence`, `missing_unit_dimension`, `missing_process_frame`, `missing_part_whole_frame`, `missing_container_frame`, `missing_temporal_frame`, `missing_route_frame`, `missing_question_target`, `blocked_ambiguity_hazard`, `blocked_provenance_gap`. `missing_scalar_equivalence`, `missing_unit_dimension`, `missing_process_frame`, `missing_part_whole_frame`, `missing_container_frame`, `missing_temporal_frame`, `missing_route_frame`, `missing_question_target`, `blocked_ambiguity_hazard`, `blocked_provenance_gap`.
- Exposes a deterministic function `classify_missing_substrate` and a CLI interface to batch-process cases. - Exposes a deterministic function `classify_missing_substrate` and a CLI interface to batch-process cases.
### Corrected label semantics (post-patch)
`missing_*` labels now mean **substrate lookup failure**, not mere trigger-surface
presence. For frame-backed categories, the classifier checks
`generate/process_frames.lookup_frame` before emitting a label:
- Text containing **give** does **not** receive `missing_process_frame` when the
`transfer` frame is registered.
- Text containing **box** does **not** receive `missing_container_frame` when
`container_packing` is registered.
- Similarly for **split** / `partition`, **drive** / `travel`, and other
registered frame triggers.
Labels that remain trigger-based (not frame lookup):
- `missing_scalar_equivalence` — unsupported numeric surfaces (`.5`, `1 / 2`, etc.)
- `missing_unit_dimension` — unknown unit-like nouns after digits
- `missing_temporal_frame` — time surfaces with no registered process frame
- `missing_question_target`, `blocked_ambiguity_hazard`, `blocked_provenance_gap`
All labels are deterministic and sorted.
--- ---
## 11. Serving Integration Status ## 11. Serving Integration Status
@ -116,7 +159,28 @@ Documented and explicitly refused surfaces in the scalar facade:
## 13. Validation ## 13. Validation
Verification has been completed across multiple lanes: ### Post-patch verification (2026-06-18)
After grounding scalar spans and tightening morphology label semantics:
1. **`git diff --check origin/main...HEAD`** — no whitespace errors.
2. **Kernel substrate unit tests** — all pass:
- `tests/test_kernel_facts.py`
- `tests/test_language_packs_scalar_equivalence.py` (includes span/provenance extraction)
- `tests/test_language_packs_unit_dimensions.py`
- `tests/test_ambiguity_hazards.py`
- `tests/test_process_frames.py`
- `tests/test_problem_frame_skeleton.py`
- `tests/test_gsm8k_morphology_missing_kernel_labels.py`
3. **Capability safety** — ADR-0128 numeric format and math candidate graph sprint tests pass.
4. **Evaluation scores (unchanged):**
- `train_sample`: 30 correct / 20 refused / 0 wrong — `wrong_ids: []`
- `holdout_dev`: 5 correct / 495 refused / 0 wrong — `wrong_ids: []`
5. **Smoke suite:** `core test --suite smoke -q` green.
### Initial tranche verification
Verification from the initial implementation:
1. **Unit tests:** 7 new test files containing 33 unit test cases verify all primitives, facade mappings, hazard lookups, and schemas. All 33 pass. 1. **Unit tests:** 7 new test files containing 33 unit test cases verify all primitives, facade mappings, hazard lookups, and schemas. All 33 pass.
2. **Capability safety:** 227 existing tests pass, confirming zero regression. 2. **Capability safety:** 227 existing tests pass, confirming zero regression.
3. **Evaluation scores:** 3. **Evaluation scores:**

View file

@ -6,12 +6,17 @@ Thin facade over ``numerics_loader.py`` exposing canonical ``Fraction``
values for scalar surfaces. Respects ADR-0128 boundaries: if a surface values for scalar surfaces. Respects ADR-0128 boundaries: if a surface
is refused by the underlying pack, this facade does not silently broaden it. is refused by the underlying pack, this facade does not silently broaden it.
``canonicalize_scalar`` is the pack-level helper for detached surfaces.
``extract_scalar_candidates`` is the text-level API that grounds spans in
problem text with ``problem_text`` provenance.
The facade MAY emit ``ScalarCandidate`` records. The facade MAY emit ``ScalarCandidate`` records.
It MAY NOT solve problems, bind base quantities, choose operations, It MAY NOT solve problems, bind base quantities, choose operations,
or infer final answers. or infer final answers.
""" """
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from fractions import Fraction from fractions import Fraction
@ -23,6 +28,7 @@ from language_packs.numerics_loader import (
lookup_fraction, lookup_fraction,
lookup_multiplier, lookup_multiplier,
match_number_format, match_number_format,
number_format_entries,
) )
@ -34,6 +40,8 @@ _UNSUPPORTED_SURFACES: tuple[str, ...] = (
"1 / 2", # spaces around slash — not a single token "1 / 2", # spaces around slash — not a single token
) )
PROVENANCE_PROBLEM_TEXT: str = "problem_text"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Source category constants — closed set per Tranche 1 spec. # Source category constants — closed set per Tranche 1 spec.
@ -62,6 +70,32 @@ _FORMAT_ID_TO_SOURCE: dict[str, str] = {
"signed_integer": SOURCE_DECIMAL, "signed_integer": SOURCE_DECIMAL,
} }
# Format search priority — longer / more specific patterns first.
_FORMAT_SEARCH_ORDER: tuple[str, ...] = (
"mixed_number",
"slash_fraction",
"percentage",
"decimal",
"thousand_separated",
"signed_integer",
)
_UNICODE_FRACTION_SYMBOLS: frozenset[str] = frozenset({"½", "¼", "¾", "", ""})
_WORD_SCALAR_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"three\s+quarters?", re.IGNORECASE),
re.compile(r"two\s+thirds?", re.IGNORECASE),
re.compile(r"one\s+quarter", re.IGNORECASE),
re.compile(r"one\s+third", re.IGNORECASE),
re.compile(r"one\s+half", re.IGNORECASE),
re.compile(r"one-half", re.IGNORECASE),
re.compile(r"one-third", re.IGNORECASE),
re.compile(r"three-quarters?", re.IGNORECASE),
re.compile(r"\bhalf\b", re.IGNORECASE),
re.compile(r"\bquarter\b", re.IGNORECASE),
re.compile(r"\bthird\b", re.IGNORECASE),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ScalarCandidate — frozen, immutable result record. # ScalarCandidate — frozen, immutable result record.
@ -75,12 +109,18 @@ class ScalarCandidate:
``entry_id`` is the ``en_numerics_v1`` entry id when the value came from ``entry_id`` is the ``en_numerics_v1`` entry id when the value came from
a pack-backed lookup, ``None`` for purely format-parsed values. a pack-backed lookup, ``None`` for purely format-parsed values.
``hazards`` carries hazard IDs from the ambiguity registry. ``hazards`` carries hazard IDs from the ambiguity registry.
Grounding fields are populated only by :func:`extract_scalar_candidates`.
Detached :func:`canonicalize_scalar` results leave them ``None``.
""" """
surface: str surface: str
canonical: Fraction canonical: Fraction
source: str source: str
entry_id: str | None entry_id: str | None
hazards: tuple[str, ...] hazards: tuple[str, ...]
source_surface: str | None = None
source_span: tuple[int, int] | None = None
provenance_kind: str | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -97,10 +137,14 @@ def _collect_hazard_ids(surface: str) -> tuple[str, ...]:
def _fraction_entry_to_candidate( def _fraction_entry_to_candidate(
surface: str, surface: str,
entry: FractionEntry, entry: FractionEntry,
*,
source_surface: str | None = None,
source_span: tuple[int, int] | None = None,
provenance_kind: str | None = None,
) -> ScalarCandidate: ) -> ScalarCandidate:
"""Convert a ``FractionEntry`` to a ``ScalarCandidate``.""" """Convert a ``FractionEntry`` to a ``ScalarCandidate``."""
canonical = Fraction(entry.numerator, entry.denominator) canonical = Fraction(entry.numerator, entry.denominator)
if entry.morphology == "fraction-symbol" or surface in {"½", "¼", "¾", "", ""}: if entry.morphology == "fraction-symbol" or surface in _UNICODE_FRACTION_SYMBOLS:
source = SOURCE_FRACTION_SYMBOL source = SOURCE_FRACTION_SYMBOL
else: else:
source = _MORPHOLOGY_TO_SOURCE.get(entry.morphology, SOURCE_FRACTION_WORD) source = _MORPHOLOGY_TO_SOURCE.get(entry.morphology, SOURCE_FRACTION_WORD)
@ -110,12 +154,19 @@ def _fraction_entry_to_candidate(
source=source, source=source,
entry_id=entry.entry_id, entry_id=entry.entry_id,
hazards=_collect_hazard_ids(surface), hazards=_collect_hazard_ids(surface),
source_surface=source_surface,
source_span=source_span,
provenance_kind=provenance_kind,
) )
def _parsed_number_to_candidate( def _parsed_number_to_candidate(
surface: str, surface: str,
parsed: ParsedNumber, parsed: ParsedNumber,
*,
source_surface: str | None = None,
source_span: tuple[int, int] | None = None,
provenance_kind: str | None = None,
) -> ScalarCandidate | None: ) -> ScalarCandidate | None:
"""Convert a ``ParsedNumber`` to a ``ScalarCandidate``. """Convert a ``ParsedNumber`` to a ``ScalarCandidate``.
@ -168,12 +219,19 @@ def _parsed_number_to_candidate(
source=source, source=source,
entry_id=None, entry_id=None,
hazards=_collect_hazard_ids(surface), hazards=_collect_hazard_ids(surface),
source_surface=source_surface,
source_span=source_span,
provenance_kind=provenance_kind,
) )
def _multiplier_entry_to_candidate( def _multiplier_entry_to_candidate(
surface: str, surface: str,
entry: MultiplierEntry, entry: MultiplierEntry,
*,
source_surface: str | None = None,
source_span: tuple[int, int] | None = None,
provenance_kind: str | None = None,
) -> ScalarCandidate: ) -> ScalarCandidate:
"""Convert a ``MultiplierEntry`` to a ``ScalarCandidate``. """Convert a ``MultiplierEntry`` to a ``ScalarCandidate``.
@ -187,6 +245,120 @@ def _multiplier_entry_to_candidate(
source=SOURCE_MULTIPLIER, source=SOURCE_MULTIPLIER,
entry_id=entry.entry_id, entry_id=entry.entry_id,
hazards=_collect_hazard_ids(surface), hazards=_collect_hazard_ids(surface),
source_surface=source_surface,
source_span=source_span,
provenance_kind=provenance_kind,
)
def _strip_regex_anchors(regex: str) -> str:
body = regex
if body.startswith("^"):
body = body[1:]
if body.endswith("$"):
body = body[:-1]
return body
def _format_search_patterns() -> tuple[re.Pattern[str], ...]:
by_id = {fmt.format_id: fmt for fmt in number_format_entries()}
patterns: list[re.Pattern[str]] = []
for format_id in _FORMAT_SEARCH_ORDER:
fmt = by_id.get(format_id)
if fmt is None:
continue
body = _strip_regex_anchors(fmt.regex)
# Allow trailing sentence punctuation; refuse embedded spaced slashes.
patterns.append(
re.compile(
r"(?<!\S)" + body + r"(?=\s|$|[.,;:!?)\]}\"'])"
)
)
return tuple(patterns)
def _is_rejected_span(text: str, start: int, end: int, surface: str) -> bool:
"""Reject ambiguous spans such as ``1`` in ``1 / 2`` or ``.5`` tokenisations."""
if surface in _UNSUPPORTED_SURFACES:
return True
if surface.startswith("."):
return True
if start > 0 and text[start - 1] == ".":
return True
if re.match(r"\s+/\s+\d", text[end:]):
return True
if re.search(r"/\s+$", text[:start]):
return True
if re.match(r"\.\d+\b", text[start:end] if start == 0 else text[max(0, start - 1):end]):
return True
return False
def _select_non_overlapping(
spans: list[tuple[int, int, str]],
) -> list[tuple[int, int, str]]:
"""Greedy longest-leftmost selection, then return in span order."""
ranked = sorted(spans, key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int, str]] = []
occupied: list[tuple[int, int]] = []
for start, end, surface in ranked:
overlaps = any(not (end <= occ_start or start >= occ_end) for occ_start, occ_end in occupied)
if overlaps:
continue
selected.append((start, end, surface))
occupied.append((start, end))
return sorted(selected, key=lambda item: item[0])
def _discover_scalar_spans(text: str) -> list[tuple[int, int, str]]:
spans: list[tuple[int, int, str]] = []
for pattern in _format_search_patterns():
for match in pattern.finditer(text):
start, end = match.start(), match.end()
surface = match.group(0)
if _is_rejected_span(text, start, end, surface):
continue
spans.append((start, end, surface))
for symbol in _UNICODE_FRACTION_SYMBOLS:
start = 0
while True:
idx = text.find(symbol, start)
if idx < 0:
break
spans.append((idx, idx + len(symbol), symbol))
start = idx + len(symbol)
for pattern in _WORD_SCALAR_PATTERNS:
for match in pattern.finditer(text):
spans.append((match.start(), match.end(), match.group(0)))
return _select_non_overlapping(spans)
def _grounded_candidate(
text: str,
start: int,
end: int,
source_surface: str,
detached: ScalarCandidate,
) -> ScalarCandidate:
if text[start:end] != source_surface:
raise ValueError(
f"source span [{start}:{end}] does not slice source_surface {source_surface!r}"
)
return ScalarCandidate(
surface=detached.surface,
canonical=detached.canonical,
source=detached.source,
entry_id=detached.entry_id,
hazards=detached.hazards,
source_surface=source_surface,
source_span=(start, end),
provenance_kind=PROVENANCE_PROBLEM_TEXT,
) )
@ -203,7 +375,7 @@ def canonicalize_scalar(surface: str) -> ScalarCandidate | None:
3. Try ``lookup_multiplier`` (handles ``half`` as multiplier). 3. Try ``lookup_multiplier`` (handles ``half`` as multiplier).
Returns ``None`` if the surface is unsupported or refused by the Returns ``None`` if the surface is unsupported or refused by the
underlying pack. underlying pack. Detached results do not carry span/provenance fields.
""" """
if not surface: if not surface:
return None return None
@ -232,6 +404,34 @@ def canonicalize_scalar(surface: str) -> ScalarCandidate | None:
return None return None
def extract_scalar_candidates(text: str) -> tuple[ScalarCandidate, ...]:
"""Extract grounded scalar candidates from problem text.
Every emitted candidate carries:
* ``source_surface`` exact substring from *text*
* ``source_span`` ``(start, end)`` character offsets
* ``provenance_kind`` ``"problem_text"``
* ``canonical`` exact ``Fraction``
* ``source`` scalar resolution kind
* ``hazards`` ambiguity hazard IDs
Candidates are returned in deterministic span order (left-to-right).
Unsupported surfaces (``.5``, ``1 / 2``, etc.) are omitted.
"""
if not text:
return ()
candidates: list[ScalarCandidate] = []
for start, end, source_surface in _discover_scalar_spans(text):
detached = canonicalize_scalar(source_surface)
if detached is None:
continue
candidates.append(_grounded_candidate(text, start, end, source_surface, detached))
return tuple(candidates)
def is_supported_scalar(surface: str) -> bool: def is_supported_scalar(surface: str) -> bool:
"""Return ``True`` if the surface can be canonicalized.""" """Return ``True`` if the surface can be canonicalized."""
return canonicalize_scalar(surface) is not None return canonicalize_scalar(surface) is not None
@ -243,4 +443,4 @@ def list_unsupported_surfaces() -> tuple[str, ...]:
These are surfaces that look numeric but are explicitly refused These are surfaces that look numeric but are explicitly refused
by the pack or this facade due to tokenisation or ambiguity issues. by the pack or this facade due to tokenisation or ambiguity issues.
""" """
return _UNSUPPORTED_SURFACES return _UNSUPPORTED_SURFACES

View file

@ -2,6 +2,10 @@
"""Classify GSM8K problems by missing substrate category. """Classify GSM8K problems by missing substrate category.
Tranche 1 broad base-layer foundations. Tranche 1 broad base-layer foundations.
Labels are semantically honest: ``missing_*`` categories fire only when a
needed substrate lookup actually fails, not merely because a trigger
surface appears in the text.
""" """
from __future__ import annotations from __future__ import annotations
@ -14,7 +18,60 @@ from typing import Sequence
from language_packs.scalar_equivalence import list_unsupported_surfaces from language_packs.scalar_equivalence import list_unsupported_surfaces
from language_packs.unit_dimensions import classify_dimension from language_packs.unit_dimensions import classify_dimension
from language_packs.loader import lookup_container from language_packs.loader import lookup_container
from generate.process_frames import all_frames from generate.process_frames import all_frames, lookup_frame
_PROCESS_FRAME_NAMES: frozenset[str] = frozenset({"transfer", "consumption", "transaction"})
_CONTAINER_FRAME_NAMES: frozenset[str] = frozenset({"container_packing"})
_PARTITION_FRAME_NAMES: frozenset[str] = frozenset({"partition"})
_TRAVEL_FRAME_NAMES: frozenset[str] = frozenset({"travel"})
_TEMPORAL_SURFACE_TRIGGERS: tuple[str, ...] = (
"hour", "hours", "minute", "minutes", "second", "seconds",
"day", "days", "week", "weeks", "month", "months", "year", "years",
)
_AMBIGUITY_HAZARD_SURFACES: tuple[str, ...] = (
"half", "quarter", "third", "percent", "percentage points", "times",
"more than", "less than", "of", "per", "each", "some", "remaining",
"left", "total", "altogether",
)
def _surface_in_text(surface: str, text_lower: str) -> bool:
"""Return True when *surface* appears as a token/phrase in *text_lower*."""
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 _frame_triggers(frame_names: frozenset[str]) -> tuple[str, ...]:
triggers: list[str] = []
for frame in all_frames():
if frame.name in frame_names:
triggers.extend(frame.trigger_surfaces)
return tuple(triggers)
def _missing_frame_for_triggers(
text_lower: str,
triggers: Sequence[str],
frame_names: frozenset[str],
) -> bool:
"""True when text contains category triggers but none resolve to a frame."""
saw_trigger = False
for trigger in triggers:
if not _surface_in_text(trigger, text_lower):
continue
saw_trigger = True
if any(frame.name in frame_names for frame in lookup_frame(trigger)):
return False
return saw_trigger
def classify_missing_substrate(problem_text: str) -> tuple[str, ...]: def classify_missing_substrate(problem_text: str) -> tuple[str, ...]:
@ -22,21 +79,18 @@ def classify_missing_substrate(problem_text: str) -> tuple[str, ...]:
Inspects problem text using substrate facades to identify gaps. Inspects problem text using substrate facades to identify gaps.
""" """
labels = set() labels: set[str] = set()
text_lower = problem_text.lower() text_lower = problem_text.lower()
# 1. missing_scalar_equivalence # 1. missing_scalar_equivalence
# If the text has unsupported surfaces like ".5" or "1 / 2"
for unsup in list_unsupported_surfaces(): for unsup in list_unsupported_surfaces():
if unsup in text_lower: if unsup in problem_text or unsup in text_lower:
labels.add("missing_scalar_equivalence") labels.add("missing_scalar_equivalence")
# Look for digit-slash-digit with spaces
if re.search(r"\b\d+\s+/\s+\d+\b", problem_text) or re.search(r"\b\.\d+\b", problem_text): if re.search(r"\b\d+\s+/\s+\d+\b", problem_text) or re.search(r"\b\.\d+\b", problem_text):
labels.add("missing_scalar_equivalence") labels.add("missing_scalar_equivalence")
# 2. missing_unit_dimension # 2. missing_unit_dimension
# Extract words following digits (e.g. "5 widgets")
matches = re.findall(r"\b\d+(?:\.\d+)?\s+([a-zA-Z]+)\b", problem_text) matches = re.findall(r"\b\d+(?:\.\d+)?\s+([a-zA-Z]+)\b", problem_text)
for word in matches: for word in matches:
word_lower = word.lower() word_lower = word.lower()
@ -48,31 +102,42 @@ def classify_missing_substrate(problem_text: str) -> tuple[str, ...]:
if classify_dimension(word_lower) is None and lookup_container(word_lower) is None: if classify_dimension(word_lower) is None and lookup_container(word_lower) is None:
labels.add("missing_unit_dimension") labels.add("missing_unit_dimension")
# 3. missing_process_frame # 3. missing_process_frame — only when process triggers fail lookup
has_triggers = False if _missing_frame_for_triggers(
for frame in all_frames(): text_lower,
for trigger in frame.trigger_surfaces: _frame_triggers(_PROCESS_FRAME_NAMES),
if f" {trigger} " in f" {text_lower} " or text_lower.startswith(trigger) or text_lower.endswith(trigger): _PROCESS_FRAME_NAMES,
has_triggers = True ):
break labels.add("missing_process_frame")
if has_triggers:
if "give" in text_lower or "gave" in text_lower or "gives" in text_lower:
labels.add("missing_process_frame")
# 4. missing_part_whole_frame # 4. missing_part_whole_frame — partition triggers must fail lookup
if any(w in text_lower for w in ["split", "divide", "share", "partition", "rest of", "portion"]): if _missing_frame_for_triggers(
text_lower,
_frame_triggers(_PARTITION_FRAME_NAMES),
_PARTITION_FRAME_NAMES,
):
labels.add("missing_part_whole_frame") labels.add("missing_part_whole_frame")
# 5. missing_container_frame # 5. missing_container_frame — container triggers must fail lookup
if any(w in text_lower for w in ["box", "pack", "bag", "fill", "contain", "crate", "carton"]): if _missing_frame_for_triggers(
text_lower,
_frame_triggers(_CONTAINER_FRAME_NAMES),
_CONTAINER_FRAME_NAMES,
):
labels.add("missing_container_frame") labels.add("missing_container_frame")
# 6. missing_temporal_frame # 6. missing_temporal_frame — temporal surfaces with no registered frame
if any(w in text_lower for w in ["hour", "minute", "day", "week", "month", "year", "work", "earn", "salary", "wage"]): for trigger in _TEMPORAL_SURFACE_TRIGGERS:
labels.add("missing_temporal_frame") if _surface_in_text(trigger, text_lower) and not lookup_frame(trigger):
labels.add("missing_temporal_frame")
break
# 7. missing_route_frame # 7. missing_route_frame — travel triggers must fail lookup
if any(w in text_lower for w in ["drive", "walk", "run", "travel", "miles per hour", "mph", "trip", "journey"]): if _missing_frame_for_triggers(
text_lower,
_frame_triggers(_TRAVEL_FRAME_NAMES),
_TRAVEL_FRAME_NAMES,
):
labels.add("missing_route_frame") labels.add("missing_route_frame")
# 8. missing_question_target # 8. missing_question_target
@ -80,13 +145,10 @@ def classify_missing_substrate(problem_text: str) -> tuple[str, ...]:
labels.add("missing_question_target") labels.add("missing_question_target")
# 9. blocked_ambiguity_hazard # 9. blocked_ambiguity_hazard
for hazard_surf in [ for hazard_surf in _AMBIGUITY_HAZARD_SURFACES:
"half", "quarter", "third", "percent", "percentage points", "times", if _surface_in_text(hazard_surf, text_lower):
"more than", "less than", "of", "per", "each", "some", "remaining",
"left", "total", "altogether"
]:
if f" {hazard_surf} " in f" {text_lower} " or text_lower.startswith(hazard_surf) or text_lower.endswith(hazard_surf):
labels.add("blocked_ambiguity_hazard") labels.add("blocked_ambiguity_hazard")
break
# 10. blocked_provenance_gap # 10. blocked_provenance_gap
if "leap year" in text_lower or "calendar" in text_lower or "world fact" in text_lower: if "leap year" in text_lower or "calendar" in text_lower or "world fact" in text_lower:
@ -148,4 +210,4 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -1,8 +1,6 @@
"""Tests for scripts/gsm8k_substrate_morphology.py.""" """Tests for scripts/gsm8k_substrate_morphology.py."""
from __future__ import annotations from __future__ import annotations
import pytest
from scripts.gsm8k_substrate_morphology import classify_missing_substrate from scripts.gsm8k_substrate_morphology import classify_missing_substrate
@ -20,27 +18,27 @@ def test_classify_missing_substrate_labels() -> None:
labels = classify_missing_substrate("There are 10 bloops in the box.") labels = classify_missing_substrate("There are 10 bloops in the box.")
assert "missing_unit_dimension" in labels assert "missing_unit_dimension" in labels
assert "missing_container_frame" in labels # "box" trigger assert "missing_container_frame" not in labels # "box" resolves to container_packing
# 3. missing_process_frame # 3. registered process frame must not be labeled missing
labels = classify_missing_substrate("John decides to give away his cards.") labels = classify_missing_substrate("John decides to give away his cards.")
assert "missing_process_frame" in labels assert "missing_process_frame" not in labels
# 4. missing_part_whole_frame # 4. registered partition frame must not be labeled missing
labels = classify_missing_substrate("Mary wants to split the money.") labels = classify_missing_substrate("Mary wants to split the money.")
assert "missing_part_whole_frame" in labels assert "missing_part_whole_frame" not in labels
# 5. missing_container_frame # 5. registered container frame must not be labeled missing
labels = classify_missing_substrate("Pack 10 apples into a bag.") labels = classify_missing_substrate("Pack 10 apples into a bag.")
assert "missing_container_frame" in labels assert "missing_container_frame" not in labels
# 6. missing_temporal_frame # 6. missing_temporal_frame for uncovered time surfaces
labels = classify_missing_substrate("John worked for 5 hours to earn money.") labels = classify_missing_substrate("John worked for 5 hours to earn money.")
assert "missing_temporal_frame" in labels assert "missing_temporal_frame" in labels
# 7. missing_route_frame # 7. registered travel frame must not be labeled missing
labels = classify_missing_substrate("They will drive a distance of 50 miles.") labels = classify_missing_substrate("They will drive a distance of 50 miles.")
assert "missing_route_frame" in labels assert "missing_route_frame" not in labels
# 8. missing_question_target # 8. missing_question_target
labels = classify_missing_substrate("Calculate the total amount.") labels = classify_missing_substrate("Calculate the total amount.")
@ -55,6 +53,12 @@ def test_classify_missing_substrate_labels() -> None:
assert "blocked_provenance_gap" in labels assert "blocked_provenance_gap" in labels
def test_registered_frames_suppress_missing_labels() -> None:
labels = classify_missing_substrate("John gives 3 apples from the box.")
assert "missing_process_frame" not in labels
assert "missing_container_frame" not in labels
def test_deterministic_and_sorted() -> None: def test_deterministic_and_sorted() -> None:
problem = "John decides to split 5 bloops into boxes during a leap year." problem = "John decides to split 5 bloops into boxes during a leap year."
labels1 = classify_missing_substrate(problem) labels1 = classify_missing_substrate(problem)
@ -62,8 +66,7 @@ def test_deterministic_and_sorted() -> None:
assert labels1 == labels2 assert labels1 == labels2
assert list(labels1) == sorted(labels1) assert list(labels1) == sorted(labels1)
# Check that multiple labels are correctly triggered assert "missing_unit_dimension" in labels1
assert "missing_unit_dimension" in labels1 # "bloops" assert "missing_part_whole_frame" not in labels1
assert "missing_part_whole_frame" in labels1 # "split" assert "missing_container_frame" not in labels1
assert "missing_container_frame" in labels1 # "boxes" assert "blocked_provenance_gap" in labels1
assert "blocked_provenance_gap" in labels1 # "leap year"

View file

@ -5,11 +5,14 @@ from fractions import Fraction
import pytest import pytest
from language_packs.scalar_equivalence import ( from language_packs.scalar_equivalence import (
PROVENANCE_PROBLEM_TEXT,
canonicalize_scalar, canonicalize_scalar,
extract_scalar_candidates,
is_supported_scalar, is_supported_scalar,
list_unsupported_surfaces, list_unsupported_surfaces,
ScalarCandidate, ScalarCandidate,
) )
from language_packs.unit_dimensions import classify_dimension
def test_fraction_words_canonicalization() -> None: def test_fraction_words_canonicalization() -> None:
@ -117,3 +120,59 @@ def test_is_supported_scalar() -> None:
assert not is_supported_scalar(".5") assert not is_supported_scalar(".5")
assert not is_supported_scalar("1 / 2") assert not is_supported_scalar("1 / 2")
assert not is_supported_scalar("random_string") assert not is_supported_scalar("random_string")
def test_detached_canonicalize_has_no_problem_text_provenance() -> None:
cand = canonicalize_scalar("half")
assert cand is not None
assert cand.provenance_kind is None
assert cand.source_span is None
assert cand.source_surface is None
def test_extract_scalar_candidates_source_span_slices_text() -> None:
text = "She ate half of a 1/2 pizza and saved 50%."
candidates = extract_scalar_candidates(text)
assert len(candidates) == 3
for cand in candidates:
assert cand.source_span is not None
start, end = cand.source_span
assert text[start:end] == cand.source_surface
assert cand.provenance_kind == PROVENANCE_PROBLEM_TEXT
def test_extract_scalar_candidates_provenance_is_problem_text() -> None:
text = "The team used 0.5 of the budget."
candidates = extract_scalar_candidates(text)
assert len(candidates) == 1
assert candidates[0].provenance_kind == PROVENANCE_PROBLEM_TEXT
assert candidates[0].source_surface == "0.5"
assert candidates[0].canonical == Fraction(1, 2)
def test_pack_level_values_do_not_masquerade_as_problem_text() -> None:
unit = classify_dimension("dollar")
assert unit is not None
assert unit.provenance_kind == "kernel_unit"
cand = canonicalize_scalar("half")
assert cand is not None
assert cand.provenance_kind is None
def test_extract_scalar_candidates_deterministic_span_order() -> None:
text = "A 1/2 share and 50% tip and half portion."
first = extract_scalar_candidates(text)
second = extract_scalar_candidates(text)
assert first == second
spans = [c.source_span for c in first]
assert spans == sorted(spans)
assert [c.source_surface for c in first] == ["1/2", "50%", "half"]
def test_extract_scalar_candidates_skip_unsupported_forms() -> None:
text = "The value is .5 and also 1 / 2 parts."
assert extract_scalar_candidates(text) == ()
assert "missing_scalar_equivalence" not in {
c.source_surface for c in extract_scalar_candidates("supported 1/2 only")
}