166 lines
5 KiB
Python
166 lines
5 KiB
Python
"""Unit and dimension facade — exact unit/dimension facts from ADR-0127.
|
|
|
|
Tranche 1 — broad base-layer foundations.
|
|
|
|
Facade over ``loader.py`` (en_units_v1) exposing dimension compatibility
|
|
checks, exact conversions, and provenance-tagged unit facts.
|
|
|
|
Non-goals:
|
|
- Fuzzy month/year conversions
|
|
- Unsupported world conversions
|
|
- Unit guessing
|
|
- Solving a problem by unit conversion alone
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from fractions import Fraction
|
|
|
|
from packs.loader import (
|
|
lookup_dimension,
|
|
lookup_unit,
|
|
get_conversion_graph,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rate dimension component mappings.
|
|
# ---------------------------------------------------------------------------
|
|
_RATE_COMPONENTS: dict[str, tuple[str, str]] = {
|
|
"wage": ("money", "time"),
|
|
"speed": ("length", "time"),
|
|
"unit_price": ("money", "count"),
|
|
"frequency": ("count", "time"),
|
|
"density": ("mass", "volume"),
|
|
"items_per_container": ("count", "container"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dataclasses — frozen, slots=True.
|
|
# ---------------------------------------------------------------------------
|
|
@dataclass(frozen=True, slots=True)
|
|
class DimensionFact:
|
|
"""A classified dimension fact for a unit token."""
|
|
surface: str
|
|
dimension: str
|
|
singular: str
|
|
plural: str
|
|
is_derived: bool
|
|
provenance_kind: str # always "kernel_unit"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ConversionFact:
|
|
"""An exact conversion fact between two compatible units."""
|
|
from_unit: str
|
|
to_unit: str
|
|
ratio: Fraction
|
|
dimension: str
|
|
provenance_kind: str # always "kernel_unit"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
def classify_dimension(token: str) -> DimensionFact | None:
|
|
"""Classify a token's dimension via the en_units_v1 pack.
|
|
|
|
Looks up the token as a unit and maps it to a DimensionFact.
|
|
Returns None if unsupported or not found in the pack.
|
|
"""
|
|
if not token:
|
|
return None
|
|
|
|
unit = lookup_unit(token)
|
|
if unit is None:
|
|
return None
|
|
|
|
dim = lookup_dimension(unit.dimension)
|
|
if dim is None:
|
|
return None
|
|
|
|
return DimensionFact(
|
|
surface=token,
|
|
dimension=unit.dimension,
|
|
singular=unit.singular,
|
|
plural=unit.plural,
|
|
is_derived=dim.is_derived,
|
|
provenance_kind="kernel_unit",
|
|
)
|
|
|
|
|
|
def are_dimensions_compatible(dim_a: str, dim_b: str) -> bool:
|
|
"""Return True if two dimensions are compatible (same base dimension)."""
|
|
dim_a_clean = dim_a.strip().lower()
|
|
dim_b_clean = dim_b.strip().lower()
|
|
|
|
if dim_a_clean == dim_b_clean:
|
|
return True
|
|
|
|
# Check if both are rate dimensions with compatible components.
|
|
if dim_a_clean in _RATE_COMPONENTS and dim_b_clean in _RATE_COMPONENTS:
|
|
num_a, den_a = _RATE_COMPONENTS[dim_a_clean]
|
|
num_b, den_b = _RATE_COMPONENTS[dim_b_clean]
|
|
return (
|
|
are_dimensions_compatible(num_a, num_b)
|
|
and are_dimensions_compatible(den_a, den_b)
|
|
)
|
|
|
|
return False
|
|
|
|
|
|
def exact_conversion(from_unit: str, to_unit: str) -> ConversionFact | None:
|
|
"""Return an exact conversion between two units if one exists in the pack.
|
|
|
|
Does not perform multi-hop conversions (returns None if no direct edge exists).
|
|
Ratios are converted to exact Fraction values.
|
|
"""
|
|
if not from_unit or not to_unit:
|
|
return None
|
|
|
|
u_from = lookup_unit(from_unit)
|
|
u_to = lookup_unit(to_unit)
|
|
|
|
if u_from is None or u_to is None:
|
|
return None
|
|
|
|
if u_from.dimension != u_to.dimension:
|
|
return None
|
|
|
|
graph = get_conversion_graph(u_from.dimension)
|
|
if not graph or not graph.edges:
|
|
return None
|
|
|
|
from_singular = u_from.singular.lower()
|
|
to_singular = u_to.singular.lower()
|
|
|
|
# Look for direct edge in the conversion graph.
|
|
for edge in graph.edges:
|
|
if edge.from_unit.lower() == from_singular and edge.to_unit.lower() == to_singular:
|
|
exact_ratio = Fraction(edge.ratio).limit_denominator(1000000)
|
|
return ConversionFact(
|
|
from_unit=u_from.singular,
|
|
to_unit=u_to.singular,
|
|
ratio=exact_ratio,
|
|
dimension=u_from.dimension,
|
|
provenance_kind="kernel_unit",
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def classify_rate_dimension(numerator_dim: str, denominator_dim: str) -> str | None:
|
|
"""Classify a rate dimension from its components."""
|
|
num = numerator_dim.strip().lower()
|
|
den = denominator_dim.strip().lower()
|
|
|
|
for rate_dim, (n, d) in _RATE_COMPONENTS.items():
|
|
if n == num and d == den:
|
|
return rate_dim
|
|
|
|
return None
|
|
|
|
|
|
def supported_dimension_families() -> tuple[str, ...]:
|
|
"""Return all supported dimension family names, sorted."""
|
|
return ("count", "length", "money", "time")
|