core/language_packs/ambiguity_hazards.py
Shay 58a94c8e4b
feat: implement kernel substrate tranche 1 base-layer foundations (#829)
* feat: implement kernel substrate tranche 1 base-layer foundations

* 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.
2026-06-18 18:46:20 -07:00

318 lines
11 KiB
Python

"""Ambiguity hazard registry — centralises known ambiguous base-level surfaces.
Tranche 1 — broad base-layer foundations.
The registry is a static, deterministic mapping built at module load.
It annotates risks and context requirements on surfaces that are
ambiguous without additional problem context.
The registry does NOT solve, filter, or make admission decisions.
It provides hazard annotations that downstream substrate consumers
(scalar facade, process frames, ProblemFrame builder) can consult.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Mapping
# ---------------------------------------------------------------------------
# Hazard categories — closed set per Tranche 1 brief.
# ---------------------------------------------------------------------------
HAZARD_CATEGORIES: frozenset[str] = frozenset({
"unbound_base_quantity",
"half_duration",
"quarter_coin",
"quarter_calendar_period",
"quarter_school_term",
"third_ordinal",
"ordinal_context",
"currency_context",
"temporal_context",
"percent_change_vs_percent_of",
"multiplicative_vs_occurrence_times",
"comparative_direction_ambiguity",
"indefinite_quantity",
"remainder_context_required",
"total_question_target_required",
"blocked_provenance_gap",
})
# ---------------------------------------------------------------------------
# Hazard record — frozen and immutable.
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class AmbiguityHazard:
"""A registered ambiguity hazard for a base-level surface.
``context_required`` lists the context signals needed to resolve
the ambiguity (e.g., ``("numeric_base_quantity",)`` for ``half``).
``disambiguation_notes`` provides human-readable guidance.
"""
hazard_id: str
surface: str
category: str
description: str
context_required: tuple[str, ...]
disambiguation_notes: str
def __post_init__(self) -> None:
if self.category not in HAZARD_CATEGORIES:
raise ValueError(
f"AmbiguityHazard.category must be one of the registered "
f"categories, got {self.category!r}"
)
# ---------------------------------------------------------------------------
# Registry data — static, exhaustive for Tranche 1 required surfaces.
# ---------------------------------------------------------------------------
def _build_registry() -> tuple[AmbiguityHazard, ...]:
"""Build the complete hazard registry.
Each entry is a unique (surface, category) pair. A single surface
may carry multiple hazards (e.g., ``quarter`` has three).
"""
entries: list[AmbiguityHazard] = []
_id = 0
def _add(
surface: str,
category: str,
description: str,
context_required: tuple[str, ...],
disambiguation_notes: str,
) -> None:
nonlocal _id
_id += 1
entries.append(AmbiguityHazard(
hazard_id=f"haz-{_id:04d}",
surface=surface,
category=category,
description=description,
context_required=context_required,
disambiguation_notes=disambiguation_notes,
))
# --- half ---
_add(
"half", "unbound_base_quantity",
"'half' requires a base quantity to halve",
("numeric_base_quantity",),
"Resolve by identifying what is being halved (e.g., 'half of 10 apples')",
)
_add(
"half", "half_duration",
"'half' may refer to a temporal duration (e.g., 'half an hour')",
("temporal_context",),
"Check if 'half' modifies a time unit",
)
# --- quarter ---
_add(
"quarter", "unbound_base_quantity",
"'quarter' as 1/4 requires a base quantity",
("numeric_base_quantity",),
"Resolve by identifying what is being quartered",
)
_add(
"quarter", "quarter_coin",
"'quarter' may refer to a US coin worth $0.25",
("currency_context",),
"Check if context involves money, coins, or currency",
)
_add(
"quarter", "quarter_calendar_period",
"'quarter' may refer to a 3-month calendar period",
("temporal_context",),
"Check if context involves calendar, fiscal, or business periods",
)
_add(
"quarter", "quarter_school_term",
"'quarter' may refer to an academic term",
("academic_context",),
"Check if context involves school, classes, or academic terms",
)
# --- third ---
_add(
"third", "unbound_base_quantity",
"'third' as 1/3 requires a base quantity",
("numeric_base_quantity",),
"Resolve by identifying what is being divided into thirds",
)
_add(
"third", "third_ordinal",
"'third' may be an ordinal (position 3), not a fraction",
("ordinal_context",),
"Check if 'third' refers to position/rank vs. fraction 1/3",
)
# --- percent ---
_add(
"percent", "percent_change_vs_percent_of",
"'percent' is ambiguous between percent-of and percent-change",
("percent_base_quantity", "change_direction"),
"Determine if percent is applied to a base (percent-of) "
"or describes a change (percent-change)",
)
# --- percentage points ---
_add(
"percentage points", "percent_change_vs_percent_of",
"'percentage points' is distinct from percent but easily confused",
("percent_base_quantity",),
"Percentage points are additive differences between percentages, "
"not multiplicative",
)
# --- times ---
_add(
"times", "multiplicative_vs_occurrence_times",
"'times' may be a multiplier (3 times as many) or an occurrence "
"count (3 times a day)",
("multiplicative_context", "occurrence_context"),
"Check if 'times' multiplies a quantity or counts occurrences",
)
# --- more than ---
_add(
"more than", "comparative_direction_ambiguity",
"'more than' requires clear reference and target quantities",
("reference_quantity", "comparison_direction"),
"Identify which quantity is the reference and which is the target",
)
# --- less than ---
_add(
"less than", "comparative_direction_ambiguity",
"'less than' requires clear reference and target quantities",
("reference_quantity", "comparison_direction"),
"Identify which quantity is the reference and which is the target",
)
# --- of ---
_add(
"of", "unbound_base_quantity",
"'of' in 'X of Y' requires identifying the base quantity Y",
("base_quantity_referent",),
"Determine if 'of' is partitive (half of), possessive, or descriptive",
)
# --- per ---
_add(
"per", "unbound_base_quantity",
"'per' introduces a rate denominator; base must be grounded",
("rate_numerator", "rate_denominator"),
"Both sides of the rate must be dimensionally grounded",
)
# --- each ---
_add(
"each", "unbound_base_quantity",
"'each' is distributive — requires knowing the set being distributed over",
("distribution_set",),
"Identify the set and the per-item quantity",
)
# --- some ---
_add(
"some", "indefinite_quantity",
"'some' is an indefinite quantifier — cannot yield a determinate value",
(),
"Refuse: 'some' does not resolve to a number. "
"Preserves wrong == 0.",
)
# --- remaining ---
_add(
"remaining", "remainder_context_required",
"'remaining' requires knowing total and consumed quantities",
("total_quantity", "consumed_quantity"),
"Identify what was the total and what was consumed/used",
)
# --- left ---
_add(
"left", "remainder_context_required",
"'left' as remainder requires total and consumed quantities",
("total_quantity", "consumed_quantity"),
"Identify what was the total and what was consumed/used; "
"'left' may also be directional (spatial)",
)
# --- total ---
_add(
"total", "total_question_target_required",
"'total' may be the question target or an intermediate sum",
("aggregation_scope",),
"Determine if 'total' is the final answer target or an intermediate value",
)
# --- altogether ---
_add(
"altogether", "total_question_target_required",
"'altogether' signals a summation question but scope must be identified",
("aggregation_scope",),
"Determine what quantities are being summed",
)
return tuple(entries)
# ---------------------------------------------------------------------------
# Index — built once, cached for process lifetime.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _HazardIndex:
"""Internal index for fast lookup by surface."""
by_surface: Mapping[str, tuple[AmbiguityHazard, ...]]
all_hazards: tuple[AmbiguityHazard, ...]
all_surfaces: tuple[str, ...]
@lru_cache(maxsize=1)
def _index() -> _HazardIndex:
registry = _build_registry()
by_surface: dict[str, list[AmbiguityHazard]] = {}
for h in registry:
by_surface.setdefault(h.surface.lower(), []).append(h)
# Deterministic ordering: sort surfaces, sort hazards within each surface by hazard_id.
sorted_surfaces = tuple(sorted(by_surface.keys()))
frozen_by_surface = {
k: tuple(sorted(v, key=lambda h: h.hazard_id))
for k, v in by_surface.items()
}
return _HazardIndex(
by_surface=frozen_by_surface,
all_hazards=registry,
all_surfaces=sorted_surfaces,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def lookup_hazards(surface: str) -> tuple[AmbiguityHazard, ...]:
"""Return all hazards registered for the given surface (case-insensitive).
Returns an empty tuple if the surface has no registered hazards.
"""
return _index().by_surface.get(surface.lower(), ())
def all_hazard_categories() -> frozenset[str]:
"""Return the closed set of hazard category strings."""
return HAZARD_CATEGORIES
def all_registered_surfaces() -> tuple[str, ...]:
"""Return all surfaces with registered hazards, sorted deterministically."""
return _index().all_surfaces
def is_hazardous(surface: str) -> bool:
"""Return True if the surface has any registered hazards."""
return surface.lower() in _index().by_surface