diff --git a/docs/analysis/kernel-substrate-tranche1-implementation-2026-06-18.md b/docs/analysis/kernel-substrate-tranche1-implementation-2026-06-18.md index cff7093d..1479fe24 100644 --- a/docs/analysis/kernel-substrate-tranche1-implementation-2026-06-18.md +++ b/docs/analysis/kernel-substrate-tranche1-implementation-2026-06-18.md @@ -28,7 +28,28 @@ Seven new Python modules have been introduced to establish the substrate layer: ## 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`. - **Symbols:** Unicode symbols (`½`, `¼`, `¾`, `⅓`, `⅔`). - **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`. - 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 @@ -116,7 +159,28 @@ Documented and explicitly refused surfaces in the scalar facade: ## 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. 2. **Capability safety:** 227 existing tests pass, confirming zero regression. 3. **Evaluation scores:** diff --git a/language_packs/scalar_equivalence.py b/language_packs/scalar_equivalence.py index 75e831f9..c70900c6 100644 --- a/language_packs/scalar_equivalence.py +++ b/language_packs/scalar_equivalence.py @@ -6,12 +6,17 @@ Thin facade over ``numerics_loader.py`` exposing canonical ``Fraction`` values for scalar surfaces. Respects ADR-0128 boundaries: if a surface 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. It MAY NOT solve problems, bind base quantities, choose operations, or infer final answers. """ from __future__ import annotations +import re from dataclasses import dataclass from fractions import Fraction @@ -23,6 +28,7 @@ from language_packs.numerics_loader import ( lookup_fraction, lookup_multiplier, match_number_format, + number_format_entries, ) @@ -34,6 +40,8 @@ _UNSUPPORTED_SURFACES: tuple[str, ...] = ( "1 / 2", # spaces around slash — not a single token ) +PROVENANCE_PROBLEM_TEXT: str = "problem_text" + # --------------------------------------------------------------------------- # Source category constants — closed set per Tranche 1 spec. @@ -62,6 +70,32 @@ _FORMAT_ID_TO_SOURCE: dict[str, str] = { "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. @@ -75,12 +109,18 @@ class ScalarCandidate: ``entry_id`` is the ``en_numerics_v1`` entry id when the value came from a pack-backed lookup, ``None`` for purely format-parsed values. ``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 canonical: Fraction source: str entry_id: str | None 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( surface: str, entry: FractionEntry, + *, + source_surface: str | None = None, + source_span: tuple[int, int] | None = None, + provenance_kind: str | None = None, ) -> ScalarCandidate: """Convert a ``FractionEntry`` to a ``ScalarCandidate``.""" 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 else: source = _MORPHOLOGY_TO_SOURCE.get(entry.morphology, SOURCE_FRACTION_WORD) @@ -110,12 +154,19 @@ def _fraction_entry_to_candidate( source=source, entry_id=entry.entry_id, hazards=_collect_hazard_ids(surface), + source_surface=source_surface, + source_span=source_span, + provenance_kind=provenance_kind, ) def _parsed_number_to_candidate( surface: str, parsed: ParsedNumber, + *, + source_surface: str | None = None, + source_span: tuple[int, int] | None = None, + provenance_kind: str | None = None, ) -> ScalarCandidate | None: """Convert a ``ParsedNumber`` to a ``ScalarCandidate``. @@ -168,12 +219,19 @@ def _parsed_number_to_candidate( source=source, entry_id=None, hazards=_collect_hazard_ids(surface), + source_surface=source_surface, + source_span=source_span, + provenance_kind=provenance_kind, ) def _multiplier_entry_to_candidate( surface: str, entry: MultiplierEntry, + *, + source_surface: str | None = None, + source_span: tuple[int, int] | None = None, + provenance_kind: str | None = None, ) -> ScalarCandidate: """Convert a ``MultiplierEntry`` to a ``ScalarCandidate``. @@ -187,6 +245,120 @@ def _multiplier_entry_to_candidate( source=SOURCE_MULTIPLIER, entry_id=entry.entry_id, 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"(? 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). 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: return None @@ -232,6 +404,34 @@ def canonicalize_scalar(surface: str) -> ScalarCandidate | 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: """Return ``True`` if the surface can be canonicalized.""" 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 by the pack or this facade due to tokenisation or ambiguity issues. """ - return _UNSUPPORTED_SURFACES + return _UNSUPPORTED_SURFACES \ No newline at end of file diff --git a/scripts/gsm8k_substrate_morphology.py b/scripts/gsm8k_substrate_morphology.py index 99df5237..300753f2 100644 --- a/scripts/gsm8k_substrate_morphology.py +++ b/scripts/gsm8k_substrate_morphology.py @@ -2,6 +2,10 @@ """Classify GSM8K problems by missing substrate category. 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 @@ -14,7 +18,60 @@ from typing import Sequence from language_packs.scalar_equivalence import list_unsupported_surfaces from language_packs.unit_dimensions import classify_dimension 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, ...]: @@ -22,21 +79,18 @@ def classify_missing_substrate(problem_text: str) -> tuple[str, ...]: Inspects problem text using substrate facades to identify gaps. """ - labels = set() + labels: set[str] = set() text_lower = problem_text.lower() # 1. missing_scalar_equivalence - # If the text has unsupported surfaces like ".5" or "1 / 2" 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") - # 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): labels.add("missing_scalar_equivalence") # 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) for word in matches: 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: labels.add("missing_unit_dimension") - # 3. missing_process_frame - has_triggers = False - for frame in all_frames(): - for trigger in frame.trigger_surfaces: - if f" {trigger} " in f" {text_lower} " or text_lower.startswith(trigger) or text_lower.endswith(trigger): - has_triggers = True - break - if has_triggers: - if "give" in text_lower or "gave" in text_lower or "gives" in text_lower: - labels.add("missing_process_frame") + # 3. missing_process_frame — only when process triggers fail lookup + if _missing_frame_for_triggers( + text_lower, + _frame_triggers(_PROCESS_FRAME_NAMES), + _PROCESS_FRAME_NAMES, + ): + labels.add("missing_process_frame") - # 4. missing_part_whole_frame - if any(w in text_lower for w in ["split", "divide", "share", "partition", "rest of", "portion"]): + # 4. missing_part_whole_frame — partition triggers must fail lookup + if _missing_frame_for_triggers( + text_lower, + _frame_triggers(_PARTITION_FRAME_NAMES), + _PARTITION_FRAME_NAMES, + ): labels.add("missing_part_whole_frame") - # 5. missing_container_frame - if any(w in text_lower for w in ["box", "pack", "bag", "fill", "contain", "crate", "carton"]): + # 5. missing_container_frame — container triggers must fail lookup + if _missing_frame_for_triggers( + text_lower, + _frame_triggers(_CONTAINER_FRAME_NAMES), + _CONTAINER_FRAME_NAMES, + ): labels.add("missing_container_frame") - # 6. missing_temporal_frame - if any(w in text_lower for w in ["hour", "minute", "day", "week", "month", "year", "work", "earn", "salary", "wage"]): - labels.add("missing_temporal_frame") + # 6. missing_temporal_frame — temporal surfaces with no registered frame + for trigger in _TEMPORAL_SURFACE_TRIGGERS: + if _surface_in_text(trigger, text_lower) and not lookup_frame(trigger): + labels.add("missing_temporal_frame") + break - # 7. missing_route_frame - if any(w in text_lower for w in ["drive", "walk", "run", "travel", "miles per hour", "mph", "trip", "journey"]): + # 7. missing_route_frame — travel triggers must fail lookup + if _missing_frame_for_triggers( + text_lower, + _frame_triggers(_TRAVEL_FRAME_NAMES), + _TRAVEL_FRAME_NAMES, + ): labels.add("missing_route_frame") # 8. missing_question_target @@ -80,13 +145,10 @@ def classify_missing_substrate(problem_text: str) -> tuple[str, ...]: labels.add("missing_question_target") # 9. blocked_ambiguity_hazard - for hazard_surf in [ - "half", "quarter", "third", "percent", "percentage points", "times", - "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): + for hazard_surf in _AMBIGUITY_HAZARD_SURFACES: + if _surface_in_text(hazard_surf, text_lower): labels.add("blocked_ambiguity_hazard") + break # 10. blocked_provenance_gap 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__": - main() + main() \ No newline at end of file diff --git a/tests/test_gsm8k_morphology_missing_kernel_labels.py b/tests/test_gsm8k_morphology_missing_kernel_labels.py index 66b282a3..ea6c3dca 100644 --- a/tests/test_gsm8k_morphology_missing_kernel_labels.py +++ b/tests/test_gsm8k_morphology_missing_kernel_labels.py @@ -1,8 +1,6 @@ """Tests for scripts/gsm8k_substrate_morphology.py.""" from __future__ import annotations -import pytest - 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.") 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.") - 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.") - 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.") - 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.") 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.") - assert "missing_route_frame" in labels + assert "missing_route_frame" not in labels # 8. missing_question_target 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 +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: problem = "John decides to split 5 bloops into boxes during a leap year." labels1 = classify_missing_substrate(problem) @@ -62,8 +66,7 @@ def test_deterministic_and_sorted() -> None: assert labels1 == labels2 assert list(labels1) == sorted(labels1) - # Check that multiple labels are correctly triggered - assert "missing_unit_dimension" in labels1 # "bloops" - assert "missing_part_whole_frame" in labels1 # "split" - assert "missing_container_frame" in labels1 # "boxes" - assert "blocked_provenance_gap" in labels1 # "leap year" + assert "missing_unit_dimension" in labels1 + assert "missing_part_whole_frame" not in labels1 + assert "missing_container_frame" not in labels1 + assert "blocked_provenance_gap" in labels1 \ No newline at end of file diff --git a/tests/test_language_packs_scalar_equivalence.py b/tests/test_language_packs_scalar_equivalence.py index 761fb53c..53af3387 100644 --- a/tests/test_language_packs_scalar_equivalence.py +++ b/tests/test_language_packs_scalar_equivalence.py @@ -5,11 +5,14 @@ from fractions import Fraction import pytest from language_packs.scalar_equivalence import ( + PROVENANCE_PROBLEM_TEXT, canonicalize_scalar, + extract_scalar_candidates, is_supported_scalar, list_unsupported_surfaces, ScalarCandidate, ) +from language_packs.unit_dimensions import classify_dimension 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("1 / 2") 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") + }